Rafael Lopez

jhola

Showing 53 changed files with 11770 additions and 858 deletions
......@@ -4,6 +4,7 @@
let {
data = [],
hoveredYear = $bindable(null),
lockedYear = $bindable(null),
height = 220,
fill = false,
marginTop = 20,
......@@ -12,6 +13,17 @@
marginLeft = 50
} = $props();
// El año activo es el fijado o el hover
let activeYear = $derived(lockedYear ?? hoveredYear);
function handleBarClick(año) {
if (lockedYear === año) {
lockedYear = null; // desfijar
} else {
lockedYear = año; // fijar
}
}
// Medir altura real del contenedor cuando fill=true
let wrapperEl = $state(null);
let measuredHeight = $state(height);
......@@ -97,24 +109,41 @@
{/each}
<!-- Barras -->
<div class="bars" onmouseleave={() => hoveredYear = null} role="group">
<div class="bars" class:has-active={!!lockedYear} onmouseleave={() => { if (!lockedYear) hoveredYear = null; }} role="group">
{#each data as d}
{@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0}
{@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0}
<div
class="bar-container"
onmouseenter={() => hoveredYear = d.año}
onmouseenter={() => { if (!lockedYear) hoveredYear = d.año; }}
onclick={() => handleBarClick(d.año)}
role="button"
tabindex="0"
>
<div
class="bar"
class:hovered={hoveredYear === d.año}
class:hovered={activeYear === d.año}
class:locked={lockedYear === d.año}
style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
></div>
</div>
{/each}
</div>
<!-- Hint -->
<div class="chart-hint">
{#if lockedYear}
<span class="hint-locked">
{lockedYear} fijado
<button class="hint-unlock" onclick={() => lockedYear = null}>soltar</button>
</span>
{:else if activeYear}
<span class="hint-hover hint-desktop-only">Click para fijar {activeYear}</span>
{:else}
<span class="hint-idle hint-desktop-only">Pasa el cursor sobre las barras</span>
<span class="hint-idle hint-mobile-only">Toca una barra para explorar</span>
{/if}
</div>
</div>
<!-- Eje X -->
......@@ -122,7 +151,7 @@
{#each data as d, i}
<span
class="x-label visible"
class:hovered={hoveredYear === d.año}
class:hovered={activeYear === d.año}
>
{String(d.año).slice(-2)}
</span>
......@@ -183,7 +212,7 @@
display: flex;
align-items: flex-end;
justify-content: center;
cursor: default;
cursor: pointer;
}
.bar {
......@@ -198,14 +227,61 @@
background: rgba(107, 159, 212, 0.85);
}
/* Fade en hover */
.bars:hover .bar {
opacity: 0.35;
}
.bars:hover .bar.hovered {
opacity: 1;
}
/* Fade permanente cuando hay año fijado */
.bars.has-active .bar {
opacity: 0.25;
}
.bars.has-active .bar.locked {
opacity: 1;
box-shadow: 0 0 0 2px var(--theme-accent, #C9A751);
}
/* Chart hint */
.chart-hint {
position: absolute;
top: -2px;
right: 0;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
}
.hint-locked {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--theme-accent, #C9A751);
opacity: 1;
}
.hint-unlock {
background: none;
border: none;
border-bottom: 1px dotted currentColor;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
opacity: 0.7;
transition: opacity 0.15s;
}
.hint-unlock:hover {
opacity: 1;
}
.hint-mobile-only { display: none; }
.hint-desktop-only { display: inline; }
@media (max-width: 768px) {
.hint-mobile-only { display: inline; }
.hint-desktop-only { display: none; }
}
.x-axis {
position: absolute;
bottom: 0;
......
......@@ -78,7 +78,6 @@
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => selectEntity(entity)}
>
<span class="item-code">{entity.entidad}</span>
<span class="item-name">{entity.entidad_desc}</span>
</button>
{/each}
......
......@@ -5,6 +5,8 @@
let isDark = $state(false);
let isMac = $state(false);
let canGoBack = $state(false);
let canGoForward = $state(false);
function toggleTheme() {
isDark = !isDark;
......@@ -12,14 +14,39 @@
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
function goBack() { history.back(); }
function goForward() { history.forward(); }
function updateNavState() {
// history.length > 1 means there's something to go back to
// We track forward availability via sessionStorage
canGoBack = history.length > 1 && sessionStorage.getItem('nav_depth') > 0;
canGoForward = sessionStorage.getItem('nav_forward') === 'true';
}
onMount(() => {
// Detect Mac for keyboard shortcut display
isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
// Read actual state from document (set by app.html)
isDark = document.documentElement.classList.contains('dark');
// Listen for theme changes (e.g., from drawer on mobile)
// Track navigation depth for back/forward state
let depth = parseInt(sessionStorage.getItem('nav_depth') || '0');
// On first visit, depth is 0
// We increment on each navigation
depth++;
sessionStorage.setItem('nav_depth', depth.toString());
canGoBack = depth > 1;
// Listen for popstate (back/forward browser actions) to enable forward
const handlePopstate = () => {
sessionStorage.setItem('nav_forward', 'true');
canGoForward = true;
const d = parseInt(sessionStorage.getItem('nav_depth') || '1');
sessionStorage.setItem('nav_depth', Math.max(0, d - 1).toString());
canGoBack = d - 1 > 0;
};
window.addEventListener('popstate', handlePopstate);
// Listen for theme changes
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
......@@ -28,12 +55,27 @@
attributeFilter: ['class']
});
return () => observer.disconnect();
return () => {
observer.disconnect();
window.removeEventListener('popstate', handlePopstate);
};
});
</script>
<!-- MÓVIL: Home + Búsqueda + Theme + hamburguesa arriba a la derecha -->
<!-- MÓVIL: Nav + Home + Búsqueda + Theme + hamburguesa -->
<div class="navbar-mobile">
<div class="nav-arrows">
<button onclick={goBack} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoBack} aria-label="Atrás" disabled={!canGoBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
<button onclick={goForward} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoForward} aria-label="Adelante" disabled={!canGoForward}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</div>
<a href="/" class="nav-home-link" aria-label="Ir al inicio">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
......@@ -76,10 +118,24 @@
</button>
</div>
<!-- DESKTOP: Barra completa con Home + Search + Theme + Menu -->
<!-- DESKTOP: Barra completa con Nav + Home + Search + Theme + Menu -->
<div class="navbar-desktop">
<div class="navbar-desktop-inner">
<!-- FLECHAS NAVEGACIÓN -->
<div class="nav-arrows">
<button onclick={goBack} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoBack} aria-label="Atrás" disabled={!canGoBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
<button onclick={goForward} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoForward} aria-label="Adelante" disabled={!canGoForward}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</div>
<!-- BOTÓN HOME -->
<div class="relative group/home">
<a href="/" class="nav-btn-icon" aria-label="Ir al inicio">
......@@ -390,4 +446,45 @@
background: rgba(0, 0, 0, 0.12);
color: #1A1A18;
}
/* Navigation arrows */
.nav-arrows {
display: flex;
align-items: center;
gap: 2px;
margin-right: 2px;
}
.nav-arrow-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
border-radius: 8px;
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-arrow-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #F5F0E8;
}
.nav-arrow-disabled {
opacity: 0.2;
cursor: default;
pointer-events: none;
}
:global(html:not(.dark)) .nav-arrow-btn {
color: #888;
}
:global(html:not(.dark)) .nav-arrow-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #1A1A18;
}
</style>
......
......@@ -25,7 +25,6 @@
{ id: 'sectores', label: '¿En qué sectores?', tecnico: 'Sectores económicos' },
{ id: 'rubro', label: '¿Con qué recursos?', tecnico: 'Rubros' },
{ id: 'organismo', label: '¿Quién financia?', tecnico: 'Organismos' },
{ id: 'fuente', label: 'Origen del ingreso', tecnico: 'Fuentes' },
];
function toggleClassifier(id) {
......@@ -40,7 +39,7 @@
}
// Clasificadores seleccionados → class_ param para Typesense
const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco' };
const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
function getActiveClasses() {
if (selectedClassifiers.length === 0) return [];
......@@ -102,6 +101,16 @@
} else if (isClass && cls === 'ubigeo') {
codigo = String(meta.municipio_ubigeo || '');
tipo = 'ubigeo';
} else if (isClass && cls === 'acteco') {
const s = meta.acteco_sector != null ? String(Math.round(meta.acteco_sector)) : '';
if (s) {
codigo = s;
if (meta.acteco_subsector != null) {
codigo += '.' + Math.round(meta.acteco_subsector);
if (meta.acteco_actividad != null) codigo += '.' + Math.round(meta.acteco_actividad);
}
}
tipo = 'acteco';
} else if (!isClass) {
// Programa/proyecto
tipo = 'programa';
......@@ -200,9 +209,9 @@
entidad: '/entidad/',
objeto_gasto: '/objeto/',
finfun: '/finfun/',
acteco: '/acteco/',
rubro: '/rubro/',
organismo: '/organismo/',
fuente: '/fuente/',
ubigeo: '/ubicacion/',
programa: '/proyecto/'
};
......@@ -279,7 +288,7 @@
bind:this={searchInput}
type="text"
class="search-modal-input"
placeholder={searchMode === 'programas' ? 'Buscar programas y proyectos...' : selectedClassifiers.length > 0 ? 'Buscar en clasificadores seleccionados...' : 'Selecciona al menos un clasificador...'}
placeholder={searchMode === 'todo' ? 'Buscar en todo...' : searchMode === 'programas' ? 'Buscar programas y proyectos...' : selectedClassifiers.length > 0 ? 'Buscar en clasificadores seleccionados...' : 'Selecciona al menos un clasificador...'}
value={searchVal}
oninput={handleInput}
/>
......@@ -291,8 +300,11 @@
<!-- Mode toggle + classifier pills -->
<div class="search-modal-filters">
<div class="mode-toggle">
<button class="mode-btn" class:active={searchMode === 'todo'} onclick={() => { searchMode = 'todo'; selectedClassifiers = []; landingSearchMode.set('todo'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}>
Todo
</button>
<button class="mode-btn" class:active={searchMode === 'programas'} onclick={() => { searchMode = 'programas'; selectedClassifiers = []; landingSearchMode.set('programas'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}>
Programas
Programas y Proyectos
</button>
<button class="mode-btn" class:active={searchMode === 'clasificadores'} onclick={() => { searchMode = 'clasificadores'; landingSearchMode.set('clasificadores'); if (searchVal.length >= 2) doSearch(searchVal); }}>
Clasificadores
......
......@@ -10,6 +10,12 @@
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
// Navegación back/forward
let canGoBack = false;
let canGoForward = false;
function goBack() { history.back(); }
function goForward() { history.forward(); }
// Estado base
let mounted = false;
let heroInView = true;
......@@ -87,10 +93,7 @@
return true;
});
// En modo todo/mixto: mantener orden de la API (relevancia)
if (isFlatMode) return hits;
// En otros modos: ordenar por criterio del usuario
// Ordenar por criterio del usuario
return [...hits].sort((a, b) => {
const dir = sortOrder === 'desc' ? 1 : -1;
if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
......@@ -105,10 +108,13 @@
// Filtrar hits según clasificadores seleccionados (la API puede devolver tipos extra)
$: filteredHits = (() => {
if (searchMode !== 'clasificadores' || selectedClassifiers.length === 0) return allHits;
const allowedClasses = new Set(selectedClassifiers.map(c => {
const map = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
return map[c] || c;
}));
// Permitir tanto el id interno como el nombre que devuelve la API
const allowedClasses = new Set();
selectedClassifiers.forEach(c => {
allowedClasses.add(c); // id interno: finfun, geografico, sectores
const apiName = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' }[c];
if (apiName) allowedClasses.add(apiName); // nombre API: ubigeo, acteco, finalidad
});
return allHits.filter(h => allowedClasses.has(h.document.class_));
})();
......@@ -350,7 +356,6 @@
{ 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.' },
{ 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.' },
{ id: 'organismo', label: '¿Quién financia?', icon: '🏦', tecnico: 'Organismos financiadores', desc: 'Origen institucional del financiamiento: Tesoro General, gobiernos subnacionales, organismos internacionales, etc.' },
{ 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.' },
];
// Modal explicativo contextual
......@@ -426,18 +431,36 @@
// Placeholder dinámico
$: placeholderText = getPlaceholderText(searchMode, selectedClassifiers);
const PLACEHOLDER_EXAMPLES = {
entidad: ['Hospital La Paz', 'Ministerio de Salud', 'UMSA', 'Gobierno Municipal de Cochabamba'],
objeto: ['Sueldos', 'Viáticos', 'Consultorías', 'Alquileres'],
finfun: ['Edad avanzada', 'Cambio climático', 'Salud', 'Justicia'],
geografico: ['Filadelfia', 'Tipuani', 'Sucre', 'Oruro'],
sectores: ['Energía eléctrica', 'Vías urbanas', 'Construcción', 'Transporte'],
rubro: ['Impuestos', 'Regalías', 'Ventas', 'Transferencias'],
organismo: ['Unión Europea', 'JICA', 'BID', 'Banco Mundial'],
};
function pickExamples(classifiers) {
if (classifiers.length === 1) {
const examples = PLACEHOLDER_EXAMPLES[classifiers[0]] || [];
return [examples[0], examples[1]].filter(Boolean);
}
return classifiers.map((id, i) => {
const examples = PLACEHOLDER_EXAMPLES[id] || [];
return examples[i % examples.length] || '';
}).filter(Boolean);
}
function getPlaceholderText(mode, classifiers) {
if (mode === 'programas') {
return 'Buscar en programas y proyectos...';
}
if (mode === 'clasificadores' && classifiers.length > 0) {
const nombres = classifiers.map(id => {
const clf = CLASIFICADORES.find(c => c.id === id);
return clf?.label?.toLowerCase() || id;
}).join(', ');
return `Buscar en ${nombres}...`;
const picked = pickExamples(classifiers);
return picked.join(', ') + '...';
}
return 'Sueldos, Hospital La Paz, Ministerio de Salud...';
return 'Hospital La Paz, Viáticos, Educación, Regalías, Tipuani...';
}
// Debounce
......@@ -561,6 +584,23 @@
let sectionObserver;
onMount(() => {
isDark = document.documentElement.classList.contains('dark');
// Nav depth tracking
let depth = parseInt(sessionStorage.getItem('nav_depth') || '0');
depth++;
sessionStorage.setItem('nav_depth', depth.toString());
canGoBack = depth > 1;
canGoForward = sessionStorage.getItem('nav_forward') === 'true';
const handlePopstate = () => {
sessionStorage.setItem('nav_forward', 'true');
canGoForward = true;
const d = parseInt(sessionStorage.getItem('nav_depth') || '1');
sessionStorage.setItem('nav_depth', Math.max(0, d - 1).toString());
canGoBack = d - 1 > 0;
};
window.addEventListener('popstate', handlePopstate);
setTimeout(() => { mounted = true; }, 80);
fetchDaily();
......@@ -753,6 +793,17 @@
url = `/objeto/${getObjetoCodigo(meta)}`;
} else if (isFinfunClass) {
url = `/finfun/${getFinfunCodigo(meta)}`;
} else if (hit.document.class_ === 'acteco') {
const s = String(meta.acteco_sector ? Math.round(meta.acteco_sector) : '');
let code = s;
if (meta.acteco_actividad !== undefined && meta.acteco_subsector !== undefined) code = `${s}.${Math.round(meta.acteco_subsector)}.${Math.round(meta.acteco_actividad)}`;
else if (meta.acteco_subsector !== undefined) code = `${s}.${Math.round(meta.acteco_subsector)}`;
url = `/acteco/${code}`;
} else if (hit.document.class_ === 'rubro') {
const code = meta.rubro_sub_cuenta || meta.rubro_cuenta || meta.rubro_clase || meta.rubro_tipo || '';
url = `/rubro/${code}`;
} else if (hit.document.class_ === 'organismo') {
url = `/organismo/${meta.organismo || ''}`;
} else if (isClassResult) {
url = `/clasificador/${hit.document.class_}/${hit.document.id}`;
} else {
......@@ -811,6 +862,9 @@
hits.forEach(h => {
const m = parseMetadatos(h.document.metadatos);
if (m.entidad) entidadesSet.add(m.entidad);
if (m.entidades && Array.isArray(m.entidades)) {
m.entidades.forEach(e => { if (e.entidad) entidadesSet.add(e.entidad); });
}
if (m.gestion) {
const years = m.gestion.split(',').map(y => parseInt(y.trim())).filter(y => !isNaN(y));
allYears.push(...years);
......@@ -1409,8 +1463,13 @@
{@const isObjetoClass = hit.document.class_ === 'objeto'}
{@const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad'}
{@const isUbigeoClass = hit.document.class_ === 'ubigeo'}
{@const isActecoClass = hit.document.class_ === 'acteco'}
{@const isRubroClass = hit.document.class_ === 'rubro'}
{@const isOrganismoClass = hit.document.class_ === 'organismo'}
{@const isClassResult = hit.document.is_class === true}
{@const hitType = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_}
{@const actecoCode = isActecoClass ? (() => { const s = String(meta.acteco_sector ? Math.round(meta.acteco_sector) : ''); if (meta.acteco_actividad !== undefined && meta.acteco_subsector !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}.${Math.round(meta.acteco_actividad)}`; if (meta.acteco_subsector !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}`; return s; })() : ''}
{@const rubroCode = isRubroClass ? (meta.rubro_sub_cuenta || meta.rubro_cuenta || meta.rubro_clase || meta.rubro_tipo || '') : ''}
<a
href={isEntidadClass
? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}`
......@@ -1420,6 +1479,12 @@
? `/objeto/${getObjetoCodigo(meta)}`
: isFinfunClass
? `/finfun/${getFinfunCodigo(meta)}`
: isActecoClass
? `/acteco/${actecoCode}`
: isRubroClass
? `/rubro/${rubroCode}`
: isOrganismoClass
? `/organismo/${meta.organismo || ''}`
: (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
class="result-card"
class:result-card-selected={selectedResultIndex === i}
......@@ -2199,12 +2264,12 @@
.clf-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:all 0.2s;font-family:var(--sans);font-size:12px;color:#A8A49C}
.clf-chip:hover{background:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.15);color:#D0CCC4}
.clf-chip-active{background:rgba(90,175,138,0.15);border-color:rgba(90,175,138,0.4);color:#6BC99E}
.clf-chip-active{background:rgba(240,193,77,0.12);border-color:rgba(240,193,77,0.4);color:#F0C14D}
.clf-chip-disabled{opacity:0.35;cursor:not-allowed}
.clf-chip-disabled:hover{background:rgba(255,255,255,0.04);border-color:rgba(255,255,255,0.08)}
.clf-dot{width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.15);flex-shrink:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
.clf-chip:hover .clf-dot{background:rgba(255,255,255,0.3)}
.clf-chip-active .clf-dot{background:#4A8B6E;box-shadow:0 0 6px rgba(74,139,110,0.3)}
.clf-chip-active .clf-dot{background:#F0C14D;box-shadow:0 0 8px rgba(240,193,77,0.5)}
.clf-label{line-height:1}
.mode-card-pills{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;align-items:center}
......@@ -2353,6 +2418,12 @@
.nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
.nav-theme-btn{background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.3s ease}
.nav-theme-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
.nav-arrows{display:flex;align-items:center;gap:2px}
.nav-arrow-btn{display:flex;align-items:center;justify-content:center;padding:6px;background:transparent;border:none;border-radius:8px;color:#B8B5AD;cursor:pointer;transition:all 0.2s}
.nav-arrow-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
.nav-arrow-disabled{opacity:0.2;cursor:default;pointer-events:none}
:global(html:not(.dark)) .nav-arrow-btn{color:#888}
:global(html:not(.dark)) .nav-arrow-btn:hover{background:rgba(0,0,0,0.06);color:#1d1d1f}
.nav-hidden{opacity:0;pointer-events:none;transform:translateY(-10px)}
/* ── DIRECTORY ── */
......
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/acteco';
// Jerarquía acteco: códigos con punto "1" → "1.1" → "1.1.1"
// Todos tienen nivel "actividad" en la API, derivamos la profundidad del código
function getDepth(codigo) {
return String(codigo).split('.').length;
}
function getParentCode(codigo) {
const parts = String(codigo).split('.');
if (parts.length <= 1) return null;
return parts.slice(0, -1).join('.');
}
export async function load({ params }) {
const { codigo } = params;
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Sector económico no encontrado');
const acteco = await res.json();
let padres = [];
let hijos = [];
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
const depth = getDepth(codigo);
// Padres: todos los ancestros
if (depth > 1) {
const parts = String(codigo).split('.');
const parentCodes = [];
for (let i = 1; i < parts.length; i++) {
parentCodes.push(parts.slice(0, i).join('.'));
}
padres = clasificador.filter(c => parentCodes.includes(c.acteco));
}
// Hijos directos: un nivel más de profundidad, mismo prefijo
const prefix = codigo + '.';
const childDepth = depth + 1;
hijos = clasificador.filter(c =>
c.acteco.startsWith(prefix) && getDepth(c.acteco) === childDepth
);
padres.sort((a, b) => a.acteco.localeCompare(b.acteco));
hijos.sort((a, b) => a.acteco.localeCompare(b.acteco));
}
} catch { /* clasificador optional */ }
return {
acteco,
padres,
hijos
};
}
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Spinner from '$lib/components/ui/Spinner.svelte';
import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import { initIndex } from '$lib/stores/searchStore';
let { data } = $props();
let actecoData = $derived(data.acteco);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(false);
let hoveredYear = $state(null);
let lockedYear = $state(null);
let activeYear = $derived(lockedYear ?? hoveredYear);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
let vista = $state('agregado'); // 'agregado' | 'comparar'
let linkCopied = $state(false);
// Inline search state
let searchQuery = $state('');
let searchResults = $state([]);
let searchLoading = $state(false);
let searchTimeout = null;
async function doSearch(q) {
if (!q || q.length < 2) { searchResults = []; return; }
searchLoading = true;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&per_page=20&is_class=true&class_=acteco`);
const data = await res.json();
searchResults = (data.hits || []).map(hit => {
const doc = hit.document || {};
let meta = {};
try { meta = typeof doc.metadatos === 'string' ? JSON.parse(doc.metadatos) : (doc.metadatos || {}); } catch {}
const s = meta.acteco_sector != null ? String(Math.round(meta.acteco_sector)) : '';
if (!s) return null;
let code = s;
if (meta.acteco_subsector != null) {
code += '.' + Math.round(meta.acteco_subsector);
if (meta.acteco_actividad != null) {
code += '.' + Math.round(meta.acteco_actividad);
}
}
return { code, label: doc.texto || '' };
}).filter(Boolean);
} catch {
searchResults = [];
}
searchLoading = false;
}
function onSearchInput(e) {
const q = e.target.value;
searchQuery = q;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => doSearch(q), 300);
}
// Parsear descripciones - la API devuelve array directo con { gestion, descripcion }
function getMaxYear(str) {
if (!str) return 0;
const years = str.match(/\d{4}/g);
return years ? Math.max(...years.map(Number)) : 0;
}
let descripciones = $derived.by(() => {
const descs = actecoData.descripciones;
if (!descs) return [];
let arr;
if (typeof descs === 'string') {
try { arr = JSON.parse(descs); } catch { return []; }
} else {
arr = Array.isArray(descs) ? descs : [];
}
// Ordenar por año más reciente primero
return [...arr].sort((a, b) => getMaxYear(b.gestion || b.rangos) - getMaxYear(a.gestion || a.rangos));
});
let variaciones = $derived(descripciones.map(d => ({ rango: d.gestion || d.rangos || '', texto: d.descripcion })));
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(entidades.length || data.nEntidades || 0);
// Datos — inicializar desde estados del server
function initEstados(obj) {
const estadosRaw = obj?.estados || [];
const seen = new Set();
const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
const hist = estados.find(d => d.gestion === 0) || null;
const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
const topPorAño = {};
anuales.forEach(d => {
topPorAño[d.gestion] = [
{ nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
{ nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
{ nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
].filter(e => e.nombre);
});
return { hist, anuales, topPorAño };
}
let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(actecoData);
let datosAnuales = $state(initAnuales);
let datosHistorico = $state(initHist);
let topEntidadesPorAño = $state(initTop);
const POBLACION = 12000000;
let actecoCodigo = $derived(actecoData.acteco);
let actecoNivel = $derived(actecoData.nivel);
let gastoPerCapita = $derived(
datosAnuales.map(d => {
const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
return {
año: d.gestion,
monto,
perCapita: metrica === 'percapita' ? perCapita : monto
};
})
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
let ranking = $derived(datosHistorico?.ranking ?? 0);
let nPares = $derived(datosHistorico?.n_pares ?? 0);
let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
let currentData = $derived(
activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
);
// Datos del año activo (fijado o hover) desde datosAnuales
let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
// KPI 1: Monto
let displayMonto = $derived.by(() => {
if (selectedEntity) {
return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
}
return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
});
let displayMontoLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `gastados en ${currentAnual.gestion}` : `gastados ${primerAño}-${ultimoAño}`;
}
return currentAnual ? `gasto total del Estado en ${currentAnual.gestion}` : `gasto total ${primerAño}-${ultimoAño}`;
});
// KPI 2: Per cápita
let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
// KPI 3: Proporción (prop)
let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
let displayPorcentajeLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `del total de esta actividad en ${currentAnual.gestion}` : 'del total de esta actividad';
}
return currentAnual ? `del gasto en ${currentAnual.gestion}` : 'del gasto total';
});
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
let nivelKey = $derived(actecoData.nivel?.toLowerCase());
let nivelPlural = $derived({ actividad: 'actividades' }[nivelKey] || 'actividades');
let nivelLabel = $derived({ actividad: 'Actividad' }[nivelKey] || actecoData.nivel || 'Actividad');
let displayRankingLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `entre las que más gastan en ${currentAnual.gestion}` : 'entre las que más gastan';
}
return currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`;
});
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
{ nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
{ nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
$effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
$effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
if (!selectedEntity) return;
lockedYear = null;
loading = true;
try {
const res = await fetch(`/api/acteco-data?codigo=${actecoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
const raw = await res.json();
console.log('[loadData] entidad:', selectedEntity.entidad, 'raw rows:', raw?.length);
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion >= 2016);
topEntidadesPorAño = {};
console.log('[loadData] historico:', datosHistorico ? { monto: datosHistorico.monto, per_capita: datosHistorico.per_capita, prop: datosHistorico.prop } : null);
// Forzar tweens directamente
if (datosHistorico) {
twMonto.set(datosHistorico.monto || 0);
twPerCapita.set(datosHistorico.per_capita || 0);
twPorcentaje.set(datosHistorico.prop || 0);
}
}
} catch (err) {
console.error('[loadData] Error:', err);
}
loading = false;
}
async function loadEntidades() {
try {
const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
const año = añosConDatos[0] || 2025;
const [entRes, clasRes] = await Promise.all([
fetch(`/api/acteco-data?codigo=${actecoCodigo}&tipo=entidades-año&gestion=${año}`),
fetch('/api/entidad-clasificador')
]);
const entData = await entRes.json();
const clasData = await clasRes.json();
const nombreMap = new Map();
if (Array.isArray(clasData)) {
clasData.forEach(e => nombreMap.set(e.entidad, e.desc_entidad));
}
if (Array.isArray(entData)) {
entidades = entData
.filter(d => d.monto > 0 && d.entidad !== 0)
.map(d => ({ entidad: d.entidad, entidad_desc: nombreMap.get(d.entidad) || `Entidad ${d.entidad}` }))
.sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
}
} catch {
entidades = [];
}
}
// ══════════════════════════════════════════════════════════════
// COMPARACIÓN
// ══════════════════════════════════════════════════════════════
const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
let hoveredYearComparar = $state(null);
let loadingComparacion = $state(false);
let isDefaultSelection = $state(false);
let alertDefaultDismissed = $state(false);
let alertRangosDismissed = $state(false);
let entidadCompararA = $state(null);
let entidadCompararB = $state(null);
let datosCompararA = $state({ anual: [], historico: null });
let datosCompararB = $state({ anual: [], historico: null });
let dropdownAOpen = $state(false);
let dropdownBOpen = $state(false);
let searchA = $state('');
let searchB = $state('');
let entityLimitA = $state(30);
let entityLimitB = $state(30);
let filteredEntidadesA = $derived.by(() => {
if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
const q = searchA.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitA);
});
let filteredEntidadesB = $derived.by(() => {
if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
const q = searchB.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitB);
});
async function loadDatosEntidad(entidad) {
try {
const res = await fetch(`/api/acteco-data?codigo=${actecoCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
const raw = await res.json();
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
}
} catch {}
return { anual: [], historico: null };
}
async function initComparacion() {
if (entidadCompararA || entidadCompararB) return;
if (selectedEntity) {
entidadCompararA = selectedEntity;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(selectedEntity);
loadingComparacion = false;
isDefaultSelection = false;
} else if (topEntidadesTotal.length >= 2) {
const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
if (top1 && top2) {
isDefaultSelection = true;
loadingComparacion = true;
entidadCompararA = top1;
datosCompararA = await loadDatosEntidad(top1);
entidadCompararB = top2;
datosCompararB = await loadDatosEntidad(top2);
loadingComparacion = false;
}
}
}
async function selectEntidadA(entidad) {
entidadCompararA = entidad;
dropdownAOpen = false;
searchA = '';
entityLimitA = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
async function selectEntidadB(entidad) {
entidadCompararB = entidad;
dropdownBOpen = false;
searchB = '';
entityLimitB = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararB = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
function handleEntityScroll(event, type) {
const el = event.target;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
}
}
let rangoAños = $state('todo');
let añosComunesTotales = $derived.by(() => {
const añosA = datosCompararA.anual.map(d => d.gestion);
const añosB = datosCompararB.anual.map(d => d.gestion);
if (añosA.length && añosB.length) {
const setB = new Set(añosB);
return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
}
if (añosA.length) return [...añosA].sort((a, b) => a - b);
if (añosB.length) return [...añosB].sort((a, b) => a - b);
return [];
});
let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
let rangoInfoA = $derived.by(() => {
if (!datosCompararA.anual.length) return null;
const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let rangoInfoB = $derived.by(() => {
if (!datosCompararB.anual.length) return null;
const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let tienenRangosDiferentes = $derived.by(() => {
if (!rangoInfoA || !rangoInfoB) return false;
return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
});
let añosComparacion = $derived.by(() => {
if (rangoAños === 'todo') return añosComunesTotales;
return añosComunesTotales.slice(-parseInt(rangoAños));
});
let datosGraficoA = $derived.by(() => {
const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
let datosGraficoB = $derived.by(() => {
const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
// Valor según métrica seleccionada
function valComparar(d) {
return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
}
let maxComparacion = $derived.by(() => {
const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
return Math.max(...all, 1) * 1.1;
});
let displayPeriodoComparar = $derived.by(() => {
if (hoveredYearComparar) return hoveredYearComparar;
if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
return 'Selecciona entidades';
});
let displayPropA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
return datosCompararA.historico?.prop || 0;
});
let displayPropB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
return datosCompararB.historico?.prop || 0;
});
let displayRankingA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
return datosCompararA.historico?.ranking || '-';
});
let displayRankingB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
return datosCompararB.historico?.ranking || '-';
});
let displayNEntidadesA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
return datosCompararA.historico?.n_entidades || '-';
});
let displayNEntidadesB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
return datosCompararB.historico?.n_entidades || '-';
});
let isDarkMode = $state(false);
$effect(() => {
if (typeof document !== 'undefined') {
const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
checkDark();
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
});
let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
function toNumber(v) { return typeof v === 'number' ? v : 0; }
$effect(() => {
const year = hoveredYearComparar;
const idx = year ? añosComparacion.indexOf(year) : -1;
twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
});
// Actualizar URL para vista comparar
function updateCompararUrl() {
if (!mounted) return;
const url = new URL($page.url);
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
else url.searchParams.delete('entidadA');
if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
else url.searchParams.delete('entidadB');
goto(url.toString(), { replaceState: true, noScroll: true });
}
// Watch vista changes
let previousVista = 'agregado';
let skipPopstate = false;
$effect(() => {
if (mounted && vista !== previousVista) {
previousVista = vista;
if (!skipPopstate) {
const url = new URL(window.location.href);
if (vista === 'comparar') {
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
} else {
url.searchParams.delete('vista');
url.searchParams.delete('entidadA');
url.searchParams.delete('entidadB');
}
history.pushState({ vista }, '', url.toString());
}
skipPopstate = false;
if (vista === 'comparar') initComparacion();
}
});
function handlePopstate(e) {
const params = new URL(window.location.href).searchParams;
const urlVista = params.get('vista') || 'agregado';
if (urlVista !== vista) {
skipPopstate = true;
vista = urlVista;
}
}
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
return m?.toLocaleString('es-BO') || '0';
}
// ══════════════════════════════════════════════════════════════
onMount(() => {
mounted = true;
initIndex();
loadEntidades().then(async () => {
// Read URL params after entidades are loaded
const urlVista = $page.url.searchParams.get('vista');
const urlEntidad = $page.url.searchParams.get('entidad');
const urlEntidadA = $page.url.searchParams.get('entidadA');
const urlEntidadB = $page.url.searchParams.get('entidadB');
if (urlVista === 'comparar') {
vista = 'comparar';
// Precargar entidades de comparación desde URL
if (urlEntidadA) {
const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
if (entA) {
entidadCompararA = entA;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entA);
}
}
if (urlEntidadB) {
const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
if (entB) {
entidadCompararB = entB;
datosCompararB = await loadDatosEntidad(entB);
}
}
loadingComparacion = false;
if (!urlEntidadA && !urlEntidadB) initComparacion();
} else if (urlEntidad && entidades.length > 0) {
const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
if (ent) selectedEntity = ent;
}
});
});
// Recargar cuando cambia el acteco (navegación entre actividades)
let prevCodigo = null;
$effect(() => {
const code = actecoCodigo;
if (prevCodigo !== null && code !== prevCodigo) {
// Reiniciar comparación
entidadCompararA = null;
entidadCompararB = null;
datosCompararA = { anual: [], historico: null };
datosCompararB = { anual: [], historico: null };
// Cargar datos del estado desde actecoData.estados
const { hist, anuales, topPorAño } = initEstados(actecoData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
// Recargar entidades y mantener la seleccionada si existe en la nueva actividad
const prevEntity = selectedEntity;
loadEntidades().then(() => {
if (prevEntity) {
const match = entidades.find(e => e.entidad === prevEntity.entidad);
if (match) {
selectedEntity = match;
loadData();
} else {
selectedEntity = null;
}
}
});
}
prevCodigo = code;
});
// Recargar cuando cambia la entidad seleccionada
let prevEntity = null;
$effect(() => {
const ent = selectedEntity;
if (ent !== prevEntity) {
prevEntity = ent;
if (ent) {
loadData();
} else if (prevCodigo) {
const { hist, anuales, topPorAño } = initEstados(actecoData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
}
// Actualizar URL
if (mounted) {
const url = new URL($page.url);
if (ent) {
url.searchParams.set('entidad', ent.entidad);
} else {
url.searchParams.delete('entidad');
}
goto(url.toString(), { replaceState: true, noScroll: true });
}
}
});
</script>
<svelte:window on:popstate={handlePopstate} />
<svelte:head>
<title>{actecoData.desc_acteco} | Presupuesto Abierto</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page" class:mounted>
{#if vista === 'agregado'}
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
<!-- Mobile toggle -->
<button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
</button>
<div class="context-card">
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<div class="inline-search">
<input
type="text"
class="inline-search-input"
placeholder="Buscar sector económico..."
value={searchQuery}
oninput={onSearchInput}
/>
{#if searchLoading}
<div class="inline-search-loading">Buscando...</div>
{/if}
{#if searchResults.length > 0}
<div class="inline-search-results">
{#each searchResults as result}
<a href="/acteco/{result.code}" class="inline-search-item" onclick={() => { searchQuery = ''; searchResults = []; }}>
<span class="inline-search-label">{result.label}</span>
</a>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
<!-- Navegación -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegación</h3>
<p class="context-hint">Actividades relacionadas. Haz click para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/acteco/{padre.acteco}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_acteco}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.acteco}</span>
<span class="tree-name">{padre.desc_acteco}</span>
</a>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{actecoData.desc_acteco}">
<span class="tree-dot"></span>
<span class="tree-code">{actecoData.acteco}</span>
<span class="tree-name">{actecoData.desc_acteco}</span>
</div>
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/acteco/{hijo.acteco}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_acteco}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.acteco}</span>
<span class="tree-name">{hijo.desc_acteco}</span>
</a>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<div class="explore-header">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<span class="explore-count">7 →</span>
</div>
<div class="explore-scroll">
<div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {actecoData.desc_acteco?.toLowerCase()}?</span>
</div>
<a href="/acteco/{actecoData.acteco}/entidades" class="explore-card">
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en {actecoData.desc_acteco?.toLowerCase()}</span>
</a>
<a href="/ubicacion?clas=acteco&codigo={actecoData.acteco}" class="explore-card">
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en {actecoData.desc_acteco?.toLowerCase()}</span>
</a>
<a href="/clasificadores/sectores?modo=mapa" class="explore-card">
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesa {actecoData.desc_acteco?.toLowerCase()} respecto a otros gastos</span>
</a>
<a href="/clasificadores/sectores?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
<span class="explore-title">Comparar actividades</span>
<span class="explore-desc">Todas las actividades entre dos momentos o entidades</span>
</a>
<a href="/clasificadores/sectores" class="explore-card">
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todos los sectores económicos y navegar entre ellos</span>
</a>
</div>
</div>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el período</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Compartir -->
<div class="context-section">
<h3 class="context-subtitle">Compartir</h3>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
</button>
</div>
</div>
</aside>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{actecoData.acteco}</span>
</div>
</div>
<h2 class="main-title-name">{actecoData.desc_acteco}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
{#if selectedEntity}
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Todo el Estado
</button>
{/if}
</div>
<div class="chart-header">
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<span class="chart-period">· {displayPeriodo}</span>
</span>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else if gastoPerCapita.length === 0 && selectedEntity}
<div class="chart-no-data">
<p>Esta entidad no tiene registros en {actecoData.desc_acteco?.toLowerCase()} desde 2016</p>
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver a Todo el Estado
</button>
</div>
{:else if gastoPerCapita.length === 0 && !selectedEntity && hijos.length > 0}
<div class="chart-no-data">
<p>Este es un sector agregado. Explora sus subsectores:</p>
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px;">
{#each hijos as hijo}
<a href="/acteco/{hijo.acteco}" class="pill pill-nivel" style="text-decoration: none;">
{hijo.desc_acteco}
</a>
{/each}
</div>
</div>
{:else if gastoPerCapita.length === 0}
<div class="chart-no-data">
<p>Sin datos disponibles para este sector</p>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<div class="ranking-header">
<h3 class="ranking-title">Concentración del gasto</h3>
<div class="ranking-summary">
<span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
<span class="ranking-summary-text">de {actecoData.desc_acteco?.toLowerCase()} lo gastan estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
</div>
</div>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<button class="ranking-card ranking-card-link" onclick={() => {
const match = entidades.find(e => e.entidad === ent.entidad);
selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
}}>
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
<svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
{/each}
</div>
</div>
{/if}
</main>
</div>
{:else}
<!-- VISTA COMPARAR -->
<main class="main-comparar">
<div class="chart-card-comparar">
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{actecoData.acteco}</span>
</div>
<VistaToggle bind:vista />
</div>
<h2 class="main-title-name">{actecoData.desc_acteco}</h2>
<div class="comparador-selectors">
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorA}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
<span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if dropdownAOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntidadesA as entity}
<button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<span class="vs">vs</span>
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorB}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
<span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if dropdownBOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntidadesB as entity}
<button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
<div class="comparador-hint">
<span>Mostrando las dos entidades con mayor gasto. Puedes cambiar la selección arriba.</span>
<button class="alert-close" onclick={() => alertDefaultDismissed = true}>✕</button>
</div>
{/if}
{#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
<div class="comparador-warning">
<span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} años en común.</span>
<button class="alert-close" onclick={() => alertRangosDismissed = true}>✕</button>
</div>
{/if}
{#if loadingComparacion}
<div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if !entidadCompararA && !entidadCompararB}
<div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
{:else if añosComparacion.length === 0}
<div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
{:else}
<div class="chart-header">
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<span class="chart-period">· {displayPeriodoComparar}</span>
</span>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
<div class="chart-comparar-wrapper">
<div class="chart-comparar-inner">
<span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
<span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
<span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
<div class="grid-line-comparar" style="bottom: 100%"></div>
<div class="grid-line-comparar" style="bottom: 50%"></div>
<div class="grid-line-comparar" style="bottom: 0%"></div>
<div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
{#each añosComparacion as año, i}
<div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
<div class="bars-pair">
<div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
<div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
</div>
</div>
{/each}
</div>
</div>
<div class="x-axis-comparar">
{#each añosComparacion as año, i}
{@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
<span class="x-label-comparar" class:visible={show}>{año}</span>
{/each}
</div>
</div>
<div class="chart-legend">
{#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
{#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
</div>
{/if}
</div>
{#if soloUnaEntidad}
<div class="comparador-hint comparador-hint-select">
<span>Selecciona la segunda entidad para ver la comparación completa.</span>
</div>
{/if}
{#if entidadCompararA && entidadCompararB}
<div class="kpis-card-comparar">
<div class="tabla-header">
<span class="tabla-label"></span>
<span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
<span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Gasto</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Per cápita</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Peso</span>
<span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Ranking</span>
<span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
<span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
</div>
</div>
{/if}
</main>
{/if}
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
height: 100dvh;
width: 100%;
max-width: 100%;
overflow: hidden;
background: var(--theme-body);
padding-top: 60px;
box-sizing: border-box;
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
padding: 2rem 2rem 0.5rem;
max-width: 1400px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.back-link:hover { color: var(--theme-accent); }
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 1rem 2rem 2rem;
height: 100%;
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
INLINE SEARCH
═══════════════════════════════════════════════════════════════ */
.inline-search {
position: relative;
}
.inline-search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 8px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.inline-search-input:focus {
border-color: var(--theme-accent);
}
.inline-search-input::placeholder {
color: var(--theme-texto);
opacity: 0.5;
}
.inline-search-loading {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
padding: 0.25rem 0;
}
.inline-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 240px;
overflow-y: auto;
z-index: 100;
}
.inline-search-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
text-decoration: none;
color: var(--theme-titulo);
font-size: 0.8125rem;
transition: background 0.15s;
}
.inline-search-item:hover {
background: var(--theme-surface-hover, rgba(201, 167, 81, 0.08));
}
.inline-search-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
min-width: 2.5rem;
}
.inline-search-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.explore-count {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
opacity: 0.7;
animation: nudgeRight 1.5s ease-in-out infinite;
}
@keyframes nudgeRight {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-scroll {
-webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
mask-image: linear-gradient(to right, black 85%, transparent 100%);
}
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-watermark {
opacity: 0.18;
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-watermark {
position: absolute;
bottom: -4px;
right: -4px;
width: 44px;
height: 44px;
opacity: 0.15;
pointer-events: none;
color: var(--theme-accent, #C9A751);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
.explore-card-disabled {
opacity: 0.3;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-sub {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
}
/* ═══════════════════════════════════════════════════════════════
DEFINICIÓN
═══════════════════════════════════════════════════════════════ */
.def-link {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
.chart-no-data {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
font-size: 0.8125rem;
text-align: center;
}
.chart-no-data p {
margin: 0;
}
.volver-agregado {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-accent);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.volver-agregado:hover { opacity: 0.7; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.copy-link-btn svg { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.15;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.metrica-toggle {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
padding: 0.125rem 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.metrica-toggle:hover {
background: rgba(201, 167, 81, 0.12);
border-color: rgba(201, 167, 81, 0.3);
color: var(--theme-accent);
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
text-align: center;
}
.kpi-value {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-desc {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
margin: 0 0 0.5rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ranking-card {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: transparent;
text-align: left;
}
.ranking-card-link {
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.ranking-card-link:hover {
background: rgba(201, 167, 81, 0.05);
}
.ranking-card-link:hover .ranking-name {
color: var(--theme-accent);
border-bottom-style: solid;
}
.ranking-arrow {
opacity: 0;
color: var(--theme-accent);
transition: opacity 0.15s, transform 0.15s;
flex-shrink: 0;
}
.ranking-card-link:hover .ranking-arrow {
opacity: 1;
transform: translateX(2px);
}
.ranking-header {
margin-bottom: 0.5rem;
}
.ranking-summary {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin-top: 0.125rem;
}
.ranking-summary-pct {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
font-weight: 700;
color: var(--theme-titulo);
}
.ranking-summary-text {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
font-size: 0.8125rem;
color: var(--theme-titulo);
border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
opacity: 0.7;
}
/* ═══════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--theme-surface);
border-radius: 16px;
max-width: 560px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--theme-borde);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.modal-close {
color: var(--theme-texto);
background: transparent;
border: none;
padding: 0.25rem;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.modal-close:hover {
background: var(--theme-borde);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.def-item {
padding: 1rem;
background: var(--theme-body);
border-radius: 10px;
border-left: 3px solid var(--theme-borde);
}
.def-current {
border-left-color: #6B9FD4;
}
.def-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.def-period {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
color: #6B9FD4;
}
.def-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
background: #4A9E6B;
padding: 0.125rem 0.5rem;
border-radius: 4px;
}
.def-text {
font-size: 0.875rem;
color: var(--theme-texto);
line-height: 1.6;
margin: 0;
}
:global(html.dark) .def-current {
border-left-color: var(--theme-accent);
}
:global(html.dark) .def-period {
color: var(--theme-accent);
}
/* ═══════════════════════════════════════════════════════════════
MOBILE SIDEBAR
═══════════════════════════════════════════════════════════════ */
.mobile-sidebar-toggle {
display: none;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
gap: 0.5rem;
height: auto;
overflow: visible;
padding: 1.5rem 1rem 2rem;
align-content: center;
min-height: calc(100vh - 4rem);
}
.dashboard-main {
overflow: visible;
}
.chart-protagonista {
flex: none;
}
.ranking-section {
flex: none;
}
.mobile-sidebar-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
color: var(--theme-titulo);
font-family: 'Qanelas', var(--font-sans);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.25rem;
width: 100%;
}
.mobile-sidebar-toggle:hover {
background: var(--theme-surface-hover);
}
.mobile-sidebar-toggle svg {
color: var(--theme-texto);
opacity: 0.6;
}
.dashboard-context .context-card {
display: none;
}
.dashboard-context.mobile-open .context-card {
display: flex;
animation: slideDown 0.25s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.context-card {
height: auto;
overflow-y: visible;
}
.page-nav {
padding: 0.75rem 1rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.dashboard-layout {
padding: 0.5rem;
}
.page-nav {
padding: 0.5rem;
}
.chart-protagonista,
.ranking-section,
.context-card {
padding: 0.5rem;
border-radius: 8px;
}
.main-title-name {
font-size: 1.375rem;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.kpi-value {
font-size: 0.8125rem;
}
.kpi-label {
font-size: 0.5625rem;
}
.explore-scroll {
gap: 6px;
}
.explore-card {
width: 100px;
height: 80px;
}
.explore-title {
font-size: 0.625rem;
}
.explore-desc {
font-size: 0.5rem;
}
.ranking-title {
font-size: 0.875rem;
}
.ranking-name {
font-size: 0.6875rem;
}
.ranking-pct {
font-size: 0.75rem;
}
.modal-content {
margin: 0.5rem;
max-height: 90vh;
}
.tree-name {
max-width: 150px;
}
}
/* ═══════════════════════════════════════════════════════════════
DARK MODE
═══════════════════════════════════════════════════════════════ */
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.5);
}
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
/* ═══════════════════════════════════════════════════════════════
COMPARAR VIEW
═══════════════════════════════════════════════════════════════ */
.main-comparar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 2rem 2rem;
width: 100%;
max-width: 1400px;
margin: 0 auto;
height: 100%;
overflow: hidden;
}
.chart-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.comparador-selectors {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
.selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
.relative { position: relative; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selector-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 8px;
font-size: 0.8125rem;
color: var(--theme-titulo);
cursor: pointer;
transition: border-color 0.2s;
}
.selector-btn:hover { border-color: var(--theme-accent); }
.comparador-btn { width: 100%; justify-content: space-between; }
.comparador-dropdown-container { position: relative; }
.dropdown-panel.comparador-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
min-width: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
}
.search-input:focus { border-color: var(--theme-accent); }
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover { background: var(--theme-surface-hover); }
.dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
.dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
.dropdown-list { max-height: 240px; overflow-y: auto; }
.entity-code { font-family: 'DM Mono', monospace; font-size: 0.6875rem; color: var(--theme-texto); min-width: 2rem; }
.comparador-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed var(--theme-borde);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
.comparador-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(107, 159, 212, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #6B9FD4;
}
.comparador-hint span { flex: 1; }
.comparador-hint-select { margin-top: 0.75rem; }
.comparador-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(230, 176, 75, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #C9A751;
}
.comparador-warning span { flex: 1; }
.alert-close {
flex-shrink: 0;
padding: 0.25rem;
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
border-radius: 4px;
}
.alert-close:hover { opacity: 1; }
.chart-comparar-wrapper {
position: relative;
width: 100%;
flex: 1;
min-height: 180px;
}
.chart-comparar-inner {
position: absolute;
top: 10px;
bottom: 30px;
left: 50px;
right: 10px;
}
.y-label-comparar {
position: absolute;
left: -50px;
width: 45px;
text-align: right;
transform: translateY(50%);
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
white-space: nowrap;
}
.grid-line-comparar {
position: absolute;
left: 0;
right: 0;
border-top: 0.5px dotted var(--theme-texto);
opacity: 0.15;
}
.bars-comparar {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: 3px;
}
.bar-group-comparar {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: pointer;
}
.bars-pair {
display: flex;
align-items: flex-end;
gap: 2px;
height: 100%;
width: 100%;
justify-content: center;
}
.bar-comparar {
flex: 1;
max-width: 24px;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: opacity 0.15s;
}
.bars-comparar:hover .bar-comparar { opacity: 0.35; }
.bar-group-comparar.active .bar-comparar { opacity: 1; }
.x-axis-comparar {
position: absolute;
bottom: 0;
left: 50px;
right: 10px;
height: 30px;
display: flex;
align-items: center;
}
.x-label-comparar {
flex: 1;
text-align: center;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s;
}
.x-label-comparar.visible { opacity: 1; }
.chart-legend {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--theme-texto);
}
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
.kpis-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem 1.25rem;
}
.tabla-header {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--theme-borde);
margin-bottom: 0.25rem;
}
.tabla-header .tabla-val {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
.tabla-entidad {
font-size: 0.6875rem;
line-height: 1.3;
word-break: break-word;
}
.tabla-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding: 0.1875rem 0;
}
.tabla-label {
font-size: 0.6875rem;
font-weight: 500;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tabla-val {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
text-align: center;
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.main-comparar { padding: 0.75rem; }
.chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
.comparador-selectors { flex-direction: column; gap: 0.75rem; }
.vs { display: none; }
}
</style>
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import * as d3 from 'd3';
let codigo = $derived($page.params.codigo);
// Estado
let loading = $state(true);
let mounted = $state(false);
let actecoInfo = $state(null);
let entidadesData = $state([]);
let selectedYear = $state(null);
let linkCopied = $state(false);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
let entidadClasificador = $state({}); // Map entidad → { desc_subarea, sigla_subarea, desc_area, ... }
let subareas = $state([]); // Lista única de subareas con color
let sliderMin = $state(0);
let sliderMax = $state(100);
// Filtrar entidades por rango de % acumulado
let filteredEntidades = $derived.by(() => {
if (sliderMin === 0 && sliderMax === 100) return entidadesData;
let acum = 0;
return entidadesData.filter(d => {
const pctBefore = (acum / totalDevengado) * 100;
acum += d.monto;
const pctAfter = (acum / totalDevengado) * 100;
return pctAfter > sliderMin && pctBefore < sliderMax;
});
});
let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
let filteredCount = $derived(filteredEntidades.length);
// Treemap
let treemapContainer = $state(null);
let treemapWidth = $state(0);
let treemapHeight = $state(0);
let treemapNodes = $state([]);
let treemapGroups = $state([]); // Nodos padre (subareas)
// Años disponibles desde gestiones del acteco
let availableYears = $derived.by(() => {
if (!actecoInfo?.gestiones) return [];
if (actecoInfo.estados?.length > 0) {
return actecoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
}
return actecoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
});
// Formatters
function formatMonto(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
return v?.toLocaleString('es-BO') || '0';
}
function formatCompact(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
return v?.toFixed(0) || '0';
}
// Colores por subarea
let isDark = $state(false);
const SUBAREA_COLORS = [
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
];
function getSubareaColor(subarea) {
const idx = subareas.indexOf(subarea);
return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
}
// Contraste de texto según luminosidad del fondo (WCAG)
function getTextColorForBg(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.95)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
function getTextColorSecondary(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.88)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
}
// Cargar clasificador de entidades
async function loadClasificador() {
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (Array.isArray(data)) {
const map = {};
data.forEach(d => { map[d.entidad] = d; });
entidadClasificador = map;
}
} catch {}
}
// Cargar info del acteco desde API
async function loadActeco() {
try {
const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=clasificador`);
const data = await res.json();
if (data && !data.error) actecoInfo = data;
} catch {}
}
// Cargar entidades desde API
async function loadEntidades() {
if (!selectedYear || !codigo) return;
loading = true;
try {
const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
const raw = await res.json();
if (Array.isArray(raw)) {
// Deduplicar por entidad
const map = new Map();
raw.forEach(d => {
if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
map.set(d.entidad, d);
}
});
entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
// Names from entidad clasificador (acteco entidades endpoint does NOT return desc_entidad)
entidadesData = entidadesData.map(d => ({
...d,
desc_entidad: entidadClasificador[d.entidad]?.desc_entidad || d.desc_entidad || `Entidad ${d.entidad}`,
desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
}));
// Extraer subareas únicas ordenadas por monto total
const subareaMontos = {};
entidadesData.forEach(d => {
subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
});
subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
buildTreemap();
}
} catch (err) {
console.error('[Entidades] Error:', err);
}
loading = false;
}
function buildTreemap() {
if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
treemapNodes = [];
treemapGroups = [];
return;
}
// Agrupar por subarea
const groups = {};
filteredEntidades.forEach(d => {
const sub = d.desc_subarea || 'Otros';
if (!groups[sub]) groups[sub] = [];
groups[sub].push({
id: d.entidad,
name: d.desc_entidad || `Entidad ${d.entidad}`,
value: d.monto,
ranking: d.ranking,
per_capita: d.per_capita,
prop: d.prop,
subarea: sub
});
});
const hierarchy = d3.hierarchy({
name: 'root',
children: Object.entries(groups).map(([subarea, children]) => ({
name: subarea,
children
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(3)
.paddingInner(1)
.paddingTop(18)
.round(true)(hierarchy);
treemapNodes = hierarchy.leaves() || [];
treemapGroups = hierarchy.children || [];
}
function changeYear(year) {
selectedYear = year;
goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
loadEntidades();
}
$effect(() => {
if (treemapContainer) {
const measure = () => {
const w = treemapContainer.clientWidth;
const h = treemapContainer.clientHeight;
if (w > 0 && h > 0) {
treemapWidth = w;
treemapHeight = h;
buildTreemap();
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(treemapContainer);
return () => observer.disconnect();
}
});
onMount(async () => {
mounted = true;
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
await Promise.all([loadActeco(), loadClasificador()]);
const yearParam = $page.url.searchParams.get('gestion');
if (yearParam) {
const y = parseInt(yearParam);
selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
} else {
selectedYear = availableYears[0] || 2025;
}
if (treemapContainer) {
treemapWidth = treemapContainer.clientWidth;
treemapHeight = treemapContainer.clientHeight;
}
await loadEntidades();
});
</script>
<svelte:head>
<title>{actecoInfo?.desc_acteco || `Sector económico ${codigo}`} - Ranking de Entidades</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page" class:mounted>
<div class="layout">
<!-- Header -->
<header class="header">
<div class="header-top">
<div class="pills">
<span class="pill pill-nivel">{actecoInfo?.nivel || 'sector económico'}</span>
<span class="pill pill-codigo">{codigo}</span>
</div>
<div class="header-actions">
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
</div>
<h1 class="title">{actecoInfo?.desc_acteco || 'Cargando...'}</h1>
<p class="subtitle">¿Qué entidades gastan más en esto?</p>
<!-- Año + Stats -->
<div class="meta-row">
<div class="year-selector">
{#each availableYears as year}
<button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
{/each}
</div>
{#if !loading && entidadesData.length > 0}
<div class="stats">
<span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
</div>
{/if}
</div>
<!-- Slider de rango -->
{#if !loading && entidadesData.length > 0}
<div class="slider-row">
<span class="slider-label">Rango: {sliderMin}% – {sliderMax}%</span>
<span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
</div>
<div class="range-slider">
<input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
<input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
</div>
{/if}
</header>
<!-- Treemap -->
<div class="treemap-wrapper" bind:this={treemapContainer}>
{#if loading}
<div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if treemapNodes.length > 0}
<svg width={treemapWidth} height={treemapHeight}>
<!-- Group labels (subareas) -->
{#each treemapGroups as group}
{@const gw = group.x1 - group.x0}
{@const color = getSubareaColor(group.data.name)}
{@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
{#if gw > 50}
<rect
x={group.x0 + 3}
y={group.y0 + 2}
width={Math.min(label.length * 6.5 + 10, gw - 6)}
height="14"
rx="3"
fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
/>
<text
x={group.x0 + 8}
y={group.y0 + 13}
fill={isDark ? '#F5F0E8' : '#1a1a1a'}
font-size="9"
font-family="'Qanelas', var(--font-sans)"
font-weight="600"
>
{label}
</text>
{/if}
{/each}
<!-- Entity nodes -->
{#each treemapNodes as node, i}
{@const w = node.x1 - node.x0}
{@const h = node.y1 - node.y0}
{@const pct = (node.value / totalDevengado) * 100}
{@const isHovered = hoveredNode === node}
{@const area = Math.sqrt(w * h)}
{@const pctSize = Math.min(20, Math.max(9, area / 8))}
{@const nameSize = Math.min(12, Math.max(7, area / 12))}
{@const color = getSubareaColor(node.data.subarea)}
<g
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
onclick={() => goto(`/acteco/${codigo}?entidad=${node.data.id}`)}
>
<rect
width={w} height={h}
fill={color}
opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
stroke-width={isHovered ? 2 : 0.5}
rx="2"
/>
{#if w > 35 && h > 22}
{@const textColor = getTextColorForBg(color)}
{@const textColorSec = getTextColorSecondary(color)}
<foreignObject x="4" y="3" width={w - 8} height={h - 6}>
<div class="node-text">
<span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
{#if h > 40 && w > 55}
<span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/each}
</svg>
{#if hoveredNode}
{@const pct = (hoveredNode.value / totalDevengado) * 100}
<div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
<span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
<span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
<span class="tooltip-name">{hoveredNode.data.name}</span>
<span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
</div>
{/if}
{:else}
<div class="loading">No hay datos para {selectedYear}</div>
{/if}
</div>
</div>
</div>
<style>
.page {
min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 1400px;
margin: 0 auto;
padding: 80px 2rem 2rem;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header { flex-shrink: 0; margin-bottom: 1rem; }
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.pills { display: flex; gap: 0.375rem; }
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
.pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
text-decoration: none;
transition: opacity 0.15s;
}
.back-link:hover { opacity: 0.7; }
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.25rem;
line-height: 1.15;
}
.subtitle {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
.year-selector::-webkit-scrollbar { display: none; }
.year-btn {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
}
.year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
.year-btn.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: white;
font-weight: 600;
}
.stats {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.stats strong { color: var(--theme-titulo); font-weight: 600; }
.stat-sep { opacity: 0.3; }
/* Slider */
.slider-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.slider-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-titulo);
font-weight: 600;
}
.slider-info {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
}
.range-slider {
position: relative;
height: 20px;
margin-bottom: 0.5rem;
}
.range-slider input[type="range"] {
position: absolute;
width: 100%;
height: 4px;
top: 8px;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range-slider input[type="range"]:first-child {
background: var(--theme-borde);
border-radius: 2px;
}
.range-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.range-slider input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Treemap */
.treemap-wrapper {
flex: 1;
min-height: 200px;
position: relative;
background: var(--theme-surface);
border-radius: 12px;
overflow: hidden;
}
.treemap-wrapper svg { display: block; width: 100%; height: 100%; }
.loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
font-size: 0.875rem;
}
.node-text {
display: flex;
flex-direction: column;
overflow: hidden;
}
.node-pct { font-weight: 700; line-height: 1; }
.node-name { line-height: 1.2; margin-top: 1px; }
/* Tooltip */
.tooltip {
position: absolute;
pointer-events: none;
padding: 0.5rem 0.75rem;
border-radius: 8px;
z-index: 10;
max-width: 250px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
color: #f5f5f5;
display: flex;
flex-direction: column;
gap: 2px;
}
:global(html:not(.dark)) .tooltip {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
.tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
.tooltip-name { font-size: 0.8125rem; font-weight: 600; }
.tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
/* Leyenda */
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding-top: 0.5rem;
flex-shrink: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.7;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
/* Responsive */
@media (max-width: 900px) {
.layout {
padding: 70px 1rem 1rem;
height: auto;
min-height: 100vh;
overflow: visible;
}
.treemap-wrapper {
min-height: 350px;
height: 50vh;
flex: none;
}
}
@media (max-width: 640px) {
.layout { padding: 60px 0.75rem 1rem; }
.title { font-size: 1.375rem; }
.subtitle { font-size: 0.75rem; }
.meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.year-selector {
flex-wrap: wrap;
gap: 0.25rem;
}
.year-btn {
font-size: 0.625rem;
padding: 0.2rem 0.5rem;
}
.slider-row {
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.stats { font-size: 0.6875rem; }
.treemap-wrapper {
min-height: 300px;
height: 45vh;
}
.tooltip {
max-width: 200px;
font-size: 0.75rem;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 380px) {
.layout { padding: 55px 0.5rem 0.75rem; }
.title { font-size: 1.125rem; }
.treemap-wrapper {
min-height: 250px;
height: 40vh;
}
.year-btn {
font-size: 0.5625rem;
padding: 0.15rem 0.375rem;
}
}
</style>
const API_BASE = 'http://136.112.29.74/api/acteco/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/acteco';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'clasificador';
const entidad = url.searchParams.get('entidad') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'entidades-lista') {
apiUrl = `${API_BASE}/${codigo}/entidades`;
} else if (tipo === 'entidad' && entidad) {
apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
} else if (tipo === 'entidades-año' && gestion) {
apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/acteco';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(acteco) {
const idx = acteco.lastIndexOf('.');
if (idx === -1) return null;
return acteco.substring(0, idx);
}
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
const estados = new Map();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.acteco}`);
if (!res.ok) return null;
const data = await res.json();
return { acteco: item.acteco, estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.acteco, r.estados));
}
cachedEstados = estados;
cachedTimestamp = now;
}
export async function GET({ url }) {
const gestion = parseInt(url.searchParams.get('gestion') || '2025');
const entidad = url.searchParams.get('entidad') || '0';
try {
if (entidad === '0') {
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const estados = cachedEstados.get(item.acteco);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel || 'actividad',
acteco: item.acteco,
desc_acteco: item.desc_acteco,
parent: getParent(item.acteco),
devengado: yearData.total
});
}
// Agregar nodos padres faltantes (sectores sin estados propios)
const ids = new Set(results.map(r => r.acteco));
const missingParents = new Set();
results.forEach(r => {
if (r.parent && !ids.has(r.parent)) missingParents.add(r.parent);
});
for (const parentCode of missingParents) {
const item = cachedClasificador.find(c => c.acteco === parentCode);
results.push({
gestion,
nivel: item?.nivel || 'actividad',
acteco: parentCode,
desc_acteco: item?.desc_acteco || parentCode,
parent: getParent(parentCode),
devengado: 0
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.acteco}/entidades/${entidad}`);
if (!res.ok) return null;
const data = await res.json();
const yearData = data.find(d => d.gestion === gestion);
if (!yearData || !yearData.monto) return null;
return {
gestion,
nivel: item.nivel || 'actividad',
acteco: item.acteco,
desc_acteco: item.desc_acteco,
parent: getParent(item.acteco),
devengado: yearData.monto
};
} catch { return null; }
}));
results.push(...batchResults.filter(Boolean));
}
// Agregar nodos padres faltantes
const ids2 = new Set(results.map(r => r.acteco));
const missing2 = new Set();
results.forEach(r => {
if (r.parent && !ids2.has(r.parent)) missing2.add(r.parent);
});
for (const parentCode of missing2) {
const item = cachedClasificador.find(c => c.acteco === parentCode);
results.push({
gestion,
nivel: item?.nivel || 'actividad',
acteco: parentCode,
desc_acteco: item?.desc_acteco || parentCode,
parent: getParent(parentCode),
devengado: 0
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......@@ -6,8 +6,7 @@ export async function GET({ url }) {
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
......@@ -18,8 +17,7 @@ export async function GET({ url }) {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
......@@ -28,8 +26,7 @@ export async function GET({ url }) {
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......
const API_BASE = 'http://136.112.29.74/api/entidad';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'detalle';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
// For DAs (codigo contains dot like "1201.1"), extract the entidad number
const entidadNum = codigo.includes('.') ? codigo.split('.')[0] : codigo;
let apiUrl;
if (tipo === 'detalle') {
apiUrl = `${API_BASE}/${entidadNum}`;
} else if (tipo === 'objetos') {
apiUrl = `${API_BASE}/${entidadNum}/objetos`;
} else if (tipo === 'finfuns') {
apiUrl = `${API_BASE}/${entidadNum}/finfuns`;
} else if (tipo === 'actecos') {
apiUrl = `${API_BASE}/${entidadNum}/actecos`;
} else if (tipo === 'rubros') {
apiUrl = `${API_BASE}/${entidadNum}/rubros`;
} else if (tipo === 'organismos') {
apiUrl = `${API_BASE}/${entidadNum}/organismos`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/finfun/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/finfun';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'clasificador';
const entidad = url.searchParams.get('entidad') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'entidades-lista') {
apiUrl = `${API_BASE}/${codigo}/entidades`;
} else if (tipo === 'entidad' && entidad) {
apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
} else if (tipo === 'entidades-año' && gestion) {
apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/finfun';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(finfun) {
const idx = finfun.lastIndexOf('.');
if (idx === -1) return null;
return finfun.substring(0, idx);
}
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
const estados = new Map();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.finfun}`);
if (!res.ok) return null;
const data = await res.json();
return { finfun: item.finfun, estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.finfun, r.estados));
}
cachedEstados = estados;
cachedTimestamp = now;
}
export async function GET({ url }) {
const gestion = parseInt(url.searchParams.get('gestion') || '2025');
const entidad = url.searchParams.get('entidad') || '0';
try {
if (entidad === '0') {
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const estados = cachedEstados.get(item.finfun);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel,
finfun: item.finfun,
desc_finfun: item.desc_finfun,
parent: getParent(item.finfun),
devengado: yearData.total
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.finfun}/entidades/${entidad}`);
if (!res.ok) return null;
const data = await res.json();
const yearData = data.find(d => d.gestion === gestion);
if (!yearData || !yearData.monto) return null;
return {
gestion,
nivel: item.nivel,
finfun: item.finfun,
desc_finfun: item.desc_finfun,
parent: getParent(item.finfun),
devengado: yearData.monto
};
} catch { return null; }
}));
results.push(...batchResults.filter(Boolean));
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......@@ -6,8 +6,7 @@ export async function GET({ url }) {
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
......@@ -18,8 +17,7 @@ export async function GET({ url }) {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
......@@ -28,8 +26,7 @@ export async function GET({ url }) {
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......
......@@ -6,10 +6,13 @@ export async function GET() {
if (!res.ok) throw new Error();
const data = await res.json();
// Mapear al formato que espera el treemap
const mapped = data.map(d => ({
const mapped = data
.filter(d => d.entidad !== 0)
.map(d => ({
entidad: d.entidad,
desc_entidad: d.desc_entidad,
sigla_entidad: d.sigla_entidad || ''
sigla_entidad: d.sigla_entidad || '',
gestiones: d.gestiones || ''
}));
return new Response(JSON.stringify(mapped), {
headers: { 'Content-Type': 'application/json' }
......
const API_BASE = 'http://136.112.29.74/api/organismo/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/organismo';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'clasificador';
const entidad = url.searchParams.get('entidad') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'entidades-lista') {
apiUrl = `${API_BASE}/${codigo}/entidades`;
} else if (tipo === 'entidad' && entidad) {
apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
} else if (tipo === 'entidades-año' && gestion) {
apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/organismo';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
// Organismo hierarchy: grupo → subgrupo → organismo
// Parent is derived from clasificador fields, not from code pattern
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
const estados = new Map();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (item) => {
try {
const code = item.organismo;
const res = await fetch(`${API_BASE}/${code}`);
if (!res.ok) return null;
const data = await res.json();
return { code: String(code), estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.code, r.estados));
}
cachedEstados = estados;
cachedTimestamp = now;
}
export async function GET({ url }) {
const gestion = parseInt(url.searchParams.get('gestion') || '2025');
const entidad = url.searchParams.get('entidad') || '0';
try {
if (entidad === '0') {
await loadAndCacheAll();
const results = [];
const grupoSet = new Set();
const subgrupoSet = new Set();
for (const item of cachedClasificador) {
const code = String(item.organismo);
const estados = cachedEstados.get(code);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
const grupoKey = `g_${item.organismo_grupo}`;
const subgrupoKey = `sg_${item.organismo_grupo}_${item.organismo_subgrupo}`;
// Add grupo node if not yet added
if (!grupoSet.has(grupoKey)) {
grupoSet.add(grupoKey);
results.push({
gestion,
nivel: 'grupo',
organismo: grupoKey,
desc_organismo: item.desc_organismo_grupo || `Grupo ${item.organismo_grupo}`,
parent: null,
devengado: 0
});
}
// Add subgrupo node if not yet added
if (!subgrupoSet.has(subgrupoKey)) {
subgrupoSet.add(subgrupoKey);
results.push({
gestion,
nivel: 'subgrupo',
organismo: subgrupoKey,
desc_organismo: item.desc_organismo_subgrupo || `Subgrupo ${item.organismo_subgrupo}`,
parent: grupoKey,
devengado: 0
});
}
results.push({
gestion,
nivel: 'organismo',
organismo: code,
desc_organismo: item.desc_organismo || '',
parent: subgrupoKey,
devengado: yearData.total
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const grupoSet2 = new Set();
const subgrupoSet2 = new Set();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (item) => {
try {
const code = String(item.organismo);
const res = await fetch(`${API_BASE}/${code}/entidades/${entidad}`);
if (!res.ok) return null;
const data = await res.json();
const yearData = data.find(d => d.gestion === gestion);
if (!yearData || !yearData.monto) return null;
return { item, code, monto: yearData.monto };
} catch { return null; }
}));
batchResults.filter(Boolean).forEach(({ item, code, monto }) => {
const grupoKey = `g_${item.organismo_grupo}`;
const subgrupoKey = `sg_${item.organismo_grupo}_${item.organismo_subgrupo}`;
if (!grupoSet2.has(grupoKey)) {
grupoSet2.add(grupoKey);
results.push({ gestion, nivel: 'grupo', organismo: grupoKey, desc_organismo: item.desc_organismo_grupo || '', parent: null, devengado: 0 });
}
if (!subgrupoSet2.has(subgrupoKey)) {
subgrupoSet2.add(subgrupoKey);
results.push({ gestion, nivel: 'subgrupo', organismo: subgrupoKey, desc_organismo: item.desc_organismo_subgrupo || '', parent: grupoKey, devengado: 0 });
}
results.push({ gestion, nivel: 'organismo', organismo: code, desc_organismo: item.desc_organismo || '', parent: subgrupoKey, devengado: monto });
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/rubro/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/rubro';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'clasificador';
const entidad = url.searchParams.get('entidad') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'entidades-lista') {
apiUrl = `${API_BASE}/${codigo}/entidades`;
} else if (tipo === 'entidad' && entidad) {
apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
} else if (tipo === 'entidades-año' && gestion) {
apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/rubro';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(code, nivel) {
const s = String(code).padStart(5, '0');
if (nivel === 'tipo') return null;
// clase (ABC00) → tipo (AB000)
if (nivel === 'clase') return s.substring(0, 2) + '000';
// cuenta (ABCD0) → clase (ABC00)
if (nivel === 'cuenta') return s.substring(0, 3) + '00';
// sub_cuenta (ABCDE) → cuenta (ABCD0)
if (nivel === 'sub_cuenta') return s.substring(0, 4) + '0';
return null;
}
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
const estados = new Map();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (item) => {
try {
const code = item.rubro;
const res = await fetch(`${API_BASE}/${code}`);
if (!res.ok) return null;
const data = await res.json();
return { code: String(code), estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.code, r.estados));
}
cachedEstados = estados;
cachedTimestamp = now;
}
export async function GET({ url }) {
const gestion = parseInt(url.searchParams.get('gestion') || '2025');
const entidad = url.searchParams.get('entidad') || '0';
try {
if (entidad === '0') {
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const code = String(item.rubro);
const estados = cachedEstados.get(code);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel || yearData.nivel || '',
rubro: code,
desc_rubro: item.desc_rubro || '',
parent: getParent(code, item.nivel || yearData.nivel),
devengado: yearData.total
});
}
// Add missing parent nodes (tipos without estados)
const ids = new Set(results.map(r => r.rubro));
const missingParents = new Set();
results.forEach(r => {
if (r.parent && !ids.has(r.parent)) missingParents.add(r.parent);
});
for (const parentCode of missingParents) {
const item = cachedClasificador.find(c => String(c.rubro) === parentCode);
results.push({
gestion,
nivel: item?.nivel || 'tipo',
rubro: parentCode,
desc_rubro: item?.desc_rubro || parentCode,
parent: getParent(parentCode, item?.nivel || 'tipo'),
devengado: 0
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (item) => {
try {
const code = String(item.rubro);
const res = await fetch(`${API_BASE}/${code}/entidades/${entidad}`);
if (!res.ok) return null;
const data = await res.json();
const yearData = data.find(d => d.gestion === gestion);
if (!yearData || !yearData.monto) return null;
return {
gestion,
nivel: item.nivel || '',
rubro: code,
desc_rubro: item.desc_rubro || '',
parent: getParent(code, item.nivel),
devengado: yearData.monto
};
} catch { return null; }
}));
results.push(...batchResults.filter(Boolean));
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/rubro';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
if (gestion) apiUrl.searchParams.set('gestion', gestion);
try {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/ubigeo';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'detalle';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'detalle') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'objetos') {
apiUrl = `${API_BASE}/${codigo}/objetos`;
} else if (tipo === 'finfuns') {
apiUrl = `${API_BASE}/${codigo}/finfuns`;
} else if (tipo === 'actecos') {
apiUrl = `${API_BASE}/${codigo}/actecos`;
} else if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/clasificador`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
This diff could not be displayed because it is too large.
......@@ -230,8 +230,13 @@
const data = await res.json();
if (Array.isArray(data)) {
entities = data.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
console.log('Entities loaded:', entities.length);
entities = data
.filter(e => {
if (!e.gestiones) return false;
const años = e.gestiones.split(',').map(Number);
return años.includes(year);
})
.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
}
if (selectedEntity) {
......@@ -787,7 +792,7 @@
// Años desde gestiones del primer grupo
if (data[0]?.gestiones) {
availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a);
availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= 2025).sort((a, b) => b - a);
selectedYear = availableYears[0];
} else {
availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
......@@ -1468,12 +1473,12 @@
<svelte:window onclick={handleClickOutsideEntity} />
<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo);">
<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo); padding-top: 60px;">
<!-- Header pedagógico -->
<header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 {viewMode === 'mapa' || viewMode === 'comparar' ? 'py-2' : 'py-6'}">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-2">
<!-- Breadcrumb: responsive -->
<nav class="{viewMode === 'comparar' ? 'mb-1' : 'mb-4'}" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
<nav class="mb-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
<!-- Móvil: solo padre -->
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
......@@ -1487,25 +1492,10 @@
</nav>
<div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
<div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}">
<h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
<h1 style="font-family: 'DM Serif Display', serif; font-weight: 400; font-size: 2rem; margin-bottom: 0.25rem; color: var(--theme-titulo);">
¿En qué se gasta?
</h1>
{#if viewMode === 'mapa'}
<button
onclick={() => showTreemapHelp = true}
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs transition-colors"
style="color: var(--theme-texto); background-color: var(--theme-fill);"
title="Cómo leer el gráfico"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span class="hidden sm:inline">Cómo leer</span>
</button>
{/if}
<div style="margin-left: auto; display: flex; align-items: center; gap: 0.75rem;">
<div class="flex items-center gap-3">
<button class="share-btn-clas" onclick={() => {
navigator.clipboard.writeText(window.location.href);
clasLinkCopied = true;
......@@ -1521,13 +1511,6 @@
</svg>
{clasLinkCopied ? 'Copiado' : 'Compartir'}
</button>
<a href="/" class="share-btn-clas" style="text-decoration: none;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver
</a>
</div>
</div>
{#if viewMode === 'mapa'}
<div class="title-selectors">
......@@ -1565,7 +1548,6 @@
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => { selectEntity(entity); titleEntityDropdownOpen = false; }}
>
<span class="option-code">{entity.entidad}</span>
<span class="option-name">{entity.desc_entidad}</span>
</button>
{/each}
......@@ -1616,7 +1598,7 @@
</div>
<!-- Controles de visualización (fuera del max-w-3xl para usar todo el ancho) -->
<div class="{viewMode === 'mapa' ? 'mt-3' : 'mt-8'}">
<div class="mt-2">
<!-- Fila principal: Toggle + Guía + Filtros -->
<div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
<!-- Selector de modo + Guía inline (visible en sm+) -->
......@@ -1770,7 +1752,7 @@
{layoutMounted ? 'sidebar-mounted' : ''}
"
>
<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">
<div class="h-full lg:sticky overflow-y-auto py-4 px-4 lg:px-0 lg:pr-6" style="top: 70px; max-height: calc(100vh - 70px);">
<!-- Cerrar en móvil -->
<div class="flex justify-between items-center mb-4 lg:hidden">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de gasto</span>
......@@ -2040,7 +2022,7 @@
<!-- Sidebar derecha: Navegación rápida -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto py-8 pl-6 border-l" style="border-color: var(--theme-borde);">
<div class="sticky overflow-y-auto py-4 pl-6 border-l" style="top: 70px; max-height: calc(100vh - 70px); border-color: var(--theme-borde);">
<p class="text-sm uppercase tracking-wide mb-4 font-medium" style="color: var(--theme-texto);">Navegación</p>
{#if selectedGrupo}
<nav class="space-y-3">
......@@ -2286,7 +2268,7 @@
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }}
onmouseleave={() => { if (!pinnedNode) setTimeout(() => { if (!tooltipHovered) hoveredNode = null; }, 30); }}
onclick={(e) => handleFlatNodeClick(node, e)}
style="cursor: pointer;"
>
......@@ -2606,7 +2588,6 @@
<button class="inline-dropdown-option" class:active={!compareA.entity} onclick={() => clearCompareEntity('A')}>Todo el Estado</button>
{#each filteredEntitiesA() as entity}
<button class="inline-dropdown-option" class:active={compareA.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('A', entity)}>
<span class="font-mono text-xs opacity-50">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
</button>
{/each}
......@@ -2768,7 +2749,6 @@
<button class="inline-dropdown-option" class:active={!compareB.entity} onclick={() => clearCompareEntity('B')}>Todo el Estado</button>
{#each filteredEntitiesB() as entity}
<button class="inline-dropdown-option" class:active={compareB.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('B', entity)}>
<span class="font-mono text-xs opacity-50">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
</button>
{/each}
......
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params, fetch }) {
......@@ -11,25 +10,17 @@ export async function load({ params, fetch }) {
throw error(400, 'Código de entidad inválido');
}
// Cargar metadata, resumen y población en paralelo
const [entidadRes, resumenRes, pobRes] = await Promise.all([
supabase
.schema('ppto')
.from('clas_institucional')
.select('*')
.eq('entidad', entidadNum)
.single(),
supabase
.schema('ppto')
.from('entidad_resumen')
.select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
.eq('codigo', codigo),
// Cargar metadata desde proxy local + población en paralelo
const [entidadRes, pobRes] = await Promise.all([
fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=detalle`).then(r => r.ok ? r.json() : null),
fetch('/poblacion.csv').then(r => r.text())
]);
// Buscar población: primero por entidad, si no suma nacional
if (!entidadRes) {
throw error(404, 'Entidad no encontrada');
}
// Población
const poblacionEntidad = {};
const poblacionNacional = {};
pobRes.split('\n').slice(1).forEach(line => {
......@@ -37,51 +28,79 @@ export async function load({ params, fetch }) {
const g = parseInt(gestion);
const p = parseInt(pob);
if (!g || !p) return;
// Suma nacional
poblacionNacional[g] = (poblacionNacional[g] || 0) + p;
// Por entidad (código sin DA)
if (ent === entidadCode) {
poblacionEntidad[g] = (poblacionEntidad[g] || 0) + p;
}
});
// Usar población de la entidad si existe, si no la nacional
const tienePobEntidad = Object.keys(poblacionEntidad).length > 0;
const poblacionMap = tienePobEntidad ? poblacionEntidad : poblacionNacional;
if (entidadRes.error || !entidadRes.data) {
throw error(404, 'Entidad no encontrada');
}
// gastos_ingresos → resumenData (compatible con el formato que espera la página)
const gastosIngresos = entidadRes.gastos_ingresos || [];
const resumenData = gastosIngresos.map(d => ({
tipo: d.tipo,
gestion: d.gestion,
devengado: d.devengado,
ranking: d.ranking,
codigo: codigo,
desc: entidadRes.desc_entidad
}));
// Determinar última gestión disponible
const gestiones = [...new Set((resumenRes.data || []).map(d => d.gestion))].sort((a, b) => b - a);
// Determinar última gestión
const gestiones = [...new Set(gastosIngresos.map(d => d.gestion))].filter(g => g <= 2025).sort((a, b) => b - a);
const ultimaGestion = gestiones[0] || 2025;
// Cargar distribuciones solo de la última gestión
const distRes = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigo)
.eq('gestion', ultimaGestion);
// Cargar distribuciones de la última gestión (objetos, finfuns, etc.)
const distEndpoints = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' },
{ api: 'rubros', dim: 'rubro' },
{ api: 'organismos', dim: 'organismo' }
];
const distResults = await Promise.all(
distEndpoints.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=${api}`);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter(d => d.gestion === ultimaGestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
const distribucionesData = distResults.flat();
// Resolver nombre de DA desde el resumen
// DA info
let nombreDA = null;
let nombreEntidadMadre = null;
if (isDA && resumenRes.data?.length > 0) {
const firstRow = resumenRes.data[0];
nombreDA = firstRow.desc || null;
nombreEntidadMadre = firstRow.desc_padre || null;
if (isDA) {
nombreDA = entidadRes.desc_entidad || null;
nombreEntidadMadre = entidadRes.desc_entidad || null;
}
return {
entidad: entidadRes.data,
entidad: entidadRes,
isDA,
nombreDA,
nombreEntidadMadre,
codigoEntidadPadre: isDA ? entidadCode : null,
resumenData: resumenRes.data || [],
distribucionesData: distRes.data || [],
resumenData,
distribucionesData,
gestionInicial: ultimaGestion,
poblacionMap,
tienePobEntidad
......
......@@ -2,7 +2,6 @@
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
let { data } = $props();
let entidad = $derived(data.entidad);
......@@ -37,15 +36,43 @@
return;
}
cargandoDist = true;
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigoSeleccionado)
.eq('gestion', gestion);
const result = rows || [];
const entidadNum = codigoSeleccionado.includes('.') ? codigoSeleccionado.split('.')[0] : codigoSeleccionado;
const dims = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' },
{ api: 'rubros', dim: 'rubro' },
{ api: 'organismos', dim: 'organismo' }
];
try {
const results = await Promise.all(
dims.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=${api}`);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter(d => d.gestion === gestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
const result = results.flat();
distCache[gestion] = result;
distribucionesData = result;
} catch {
distribucionesData = [];
}
cargandoDist = false;
}
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código finfun
// Jerarquía: Finalidad (1-9 o 10) → Grupo Función (2-3 dígitos) → Función (3+ dígitos)
// Nota: Finalidad 10 es especial - tiene 2 dígitos, sus hijos empiezan con "10"
const API_BASE = 'http://136.112.29.74/api/finfun';
function getFinalidadCode(finfun) {
const code = String(finfun);
// Si empieza con "10", la finalidad es "10"
if (code.startsWith('10')) {
return '10';
}
// Si no, la finalidad es el primer dígito
return code.charAt(0);
// Jerarquía finfun: Finalidad → Grupo Función → Función
// Códigos con punto: "1" (finalidad), "1.1" (grpfuncion), "1.1.1" (función)
function getNivel(codigo) {
const parts = String(codigo).split('.');
if (parts.length === 1) return 'finalidad';
if (parts.length === 2) return 'grpfuncion';
return 'funcion';
}
function getGrpFuncionCode(finfun) {
const code = String(finfun);
// Si empieza con "10", el grupo función son los primeros 3 dígitos (ej: "101", "102")
if (code.startsWith('10')) {
return code.substring(0, 3);
}
// Si no, son los primeros 2 dígitos (ej: "11", "21", "93")
return code.substring(0, 2);
function getFinalidadCode(codigo) {
return String(codigo).split('.')[0];
}
function getNivel(finfun) {
const code = String(finfun);
// Finalidades 1-9 tienen longitud 1, finalidad 10 tiene longitud 2
if (code.length === 1) return 'finalidad';
if (code === '10') return 'finalidad';
// GrpFuncion: 2 dígitos para 1-9, 3 dígitos para 10
if (code.startsWith('10')) {
if (code.length === 3) return 'grpfuncion';
return 'funcion';
}
if (code.length === 2) return 'grpfuncion';
return 'funcion';
function getGrpFuncionCode(codigo) {
const parts = String(codigo).split('.');
return parts.slice(0, 2).join('.');
}
export async function load({ params }) {
console.time('[SERVER] Total load finfun');
const { codigo } = params;
console.time('[SERVER] Query clas_finfun');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('finfun', codigo);
console.timeEnd('[SERVER] Query clas_finfun');
if (dbError || !data || data.length === 0) {
throw error(404, 'Finalidad/Función no encontrada');
}
// Cargar datos del finfun desde la API
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Finalidad/Función no encontrada');
const finfun = await res.json();
// Tomar el primer resultado
const finfun = data[0];
const nivel = finfun.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
// Cargar clasificador completo para derivar padres e hijos
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
// Buscar padres según nivel
// Padres
if (nivel === 'funcion') {
// Padres: finalidad y grupo función
const finalidadCode = getFinalidadCode(codigo);
const grpFuncionCode = getGrpFuncionCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.in('finfun', [finalidadCode, grpFuncionCode])
.order('finfun');
if (padresData) padres = padresData;
const grpCode = getGrpFuncionCode(codigo);
padres = clasificador.filter(c => c.finfun === finalidadCode || c.finfun === grpCode);
} else if (nivel === 'grpfuncion') {
// Padre: finalidad
const finalidadCode = getFinalidadCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('finfun', finalidadCode);
if (padresData) padres = padresData;
padres = clasificador.filter(c => c.finfun === finalidadCode);
}
// finalidad no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
// Hijos
if (nivel === 'finalidad') {
// Hijos: grupos de función de esta finalidad
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'grpfuncion')
.order('finfun');
// Filtrar solo los hijos directos de esta finalidad
// Para finalidad "1": hijos son "11", "12", ..., "19" (NO "10x")
// Para finalidad "10": hijos son "101", "102", ..., "109"
if (hijosData) {
hijos = hijosData.filter(h => {
const hijoPadre = getFinalidadCode(h.finfun);
return hijoPadre === codigo;
});
}
hijos = clasificador.filter(c =>
c.nivel === 'grpfuncion' && getFinalidadCode(c.finfun) === codigo && c.finfun !== codigo
);
} else if (nivel === 'grpfuncion') {
// Hijos: funciones de este grupo
const grpCode = getGrpFuncionCode(codigo);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'funcion')
.order('finfun');
// Filtrar solo los hijos directos de este grupo
if (hijosData) {
hijos = hijosData.filter(h => {
const hijoGrp = getGrpFuncionCode(h.finfun);
return hijoGrp === grpCode;
});
hijos = clasificador.filter(c =>
c.nivel === 'funcion' && getGrpFuncionCode(c.finfun) === codigo
);
}
}
// funcion no tiene hijos
console.timeEnd('[SERVER] Query hijos');
padres.sort((a, b) => a.finfun.localeCompare(b.finfun));
hijos.sort((a, b) => a.finfun.localeCompare(b.finfun));
}
} catch { /* clasificador optional */ }
console.timeEnd('[SERVER] Total load finfun');
return {
finfun,
padres,
......
This diff could not be displayed because it is too large.
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import * as d3 from 'd3';
let codigo = $derived($page.params.codigo);
// Estado
let loading = $state(true);
let mounted = $state(false);
let finfunInfo = $state(null);
let entidadesData = $state([]);
let selectedYear = $state(null);
let linkCopied = $state(false);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
let entidadClasificador = $state({}); // Map entidad → { desc_subarea, sigla_subarea, desc_area, ... }
let subareas = $state([]); // Lista única de subareas con color
let sliderMin = $state(0);
let sliderMax = $state(100);
// Filtrar entidades por rango de % acumulado
let filteredEntidades = $derived.by(() => {
if (sliderMin === 0 && sliderMax === 100) return entidadesData;
let acum = 0;
return entidadesData.filter(d => {
const pctBefore = (acum / totalDevengado) * 100;
acum += d.monto;
const pctAfter = (acum / totalDevengado) * 100;
return pctAfter > sliderMin && pctBefore < sliderMax;
});
});
let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
let filteredCount = $derived(filteredEntidades.length);
// Treemap
let treemapContainer = $state(null);
let treemapWidth = $state(0);
let treemapHeight = $state(0);
let treemapNodes = $state([]);
let treemapGroups = $state([]); // Nodos padre (subareas)
// Años disponibles desde gestiones del finfun
let availableYears = $derived.by(() => {
if (!finfunInfo?.gestiones) return [];
if (finfunInfo.estados?.length > 0) {
return finfunInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
}
return finfunInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
});
// Formatters
function formatMonto(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
return v?.toLocaleString('es-BO') || '0';
}
function formatCompact(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
return v?.toFixed(0) || '0';
}
// Colores por subarea
let isDark = $state(false);
const SUBAREA_COLORS = [
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
];
function getSubareaColor(subarea) {
const idx = subareas.indexOf(subarea);
return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
}
// Contraste de texto según luminosidad del fondo (WCAG)
function getTextColorForBg(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.95)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
function getTextColorSecondary(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.88)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
}
// Cargar clasificador de entidades
async function loadClasificador() {
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (Array.isArray(data)) {
const map = {};
data.forEach(d => { map[d.entidad] = d; });
entidadClasificador = map;
}
} catch {}
}
// Cargar info del finfun desde API
async function loadFinfun() {
try {
const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=clasificador`);
const data = await res.json();
if (data && !data.error) finfunInfo = data;
} catch {}
}
// Cargar entidades desde API
async function loadEntidades() {
if (!selectedYear || !codigo) return;
loading = true;
try {
const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
const raw = await res.json();
if (Array.isArray(raw)) {
// Deduplicar por entidad
const map = new Map();
raw.forEach(d => {
if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
map.set(d.entidad, d);
}
});
entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
// Resolve entidad names from the clasificador map
entidadesData = entidadesData.map(d => ({
...d,
desc_entidad: entidadClasificador[d.entidad]?.desc_entidad || d.desc_entidad || `Entidad ${d.entidad}`,
desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
}));
// Extraer subareas únicas ordenadas por monto total
const subareaMontos = {};
entidadesData.forEach(d => {
subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
});
subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
buildTreemap();
}
} catch (err) {
console.error('[Entidades] Error:', err);
}
loading = false;
}
function buildTreemap() {
if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
treemapNodes = [];
treemapGroups = [];
return;
}
// Agrupar por subarea
const groups = {};
filteredEntidades.forEach(d => {
const sub = d.desc_subarea || 'Otros';
if (!groups[sub]) groups[sub] = [];
groups[sub].push({
id: d.entidad,
name: d.desc_entidad || `Entidad ${d.entidad}`,
value: d.monto,
ranking: d.ranking,
per_capita: d.per_capita,
prop: d.prop,
subarea: sub
});
});
const hierarchy = d3.hierarchy({
name: 'root',
children: Object.entries(groups).map(([subarea, children]) => ({
name: subarea,
children
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(3)
.paddingInner(1)
.paddingTop(18)
.round(true)(hierarchy);
treemapNodes = hierarchy.leaves() || [];
treemapGroups = hierarchy.children || [];
}
function changeYear(year) {
selectedYear = year;
goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
loadEntidades();
}
$effect(() => {
if (treemapContainer) {
const measure = () => {
const w = treemapContainer.clientWidth;
const h = treemapContainer.clientHeight;
if (w > 0 && h > 0) {
treemapWidth = w;
treemapHeight = h;
buildTreemap();
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(treemapContainer);
return () => observer.disconnect();
}
});
onMount(async () => {
mounted = true;
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
await Promise.all([loadFinfun(), loadClasificador()]);
const yearParam = $page.url.searchParams.get('gestion');
if (yearParam) {
const y = parseInt(yearParam);
selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
} else {
selectedYear = availableYears[0] || 2025;
}
if (treemapContainer) {
treemapWidth = treemapContainer.clientWidth;
treemapHeight = treemapContainer.clientHeight;
}
await loadEntidades();
});
</script>
<svelte:head>
<title>{finfunInfo?.desc_finfun || `Finalidad y función ${codigo}`} - Ranking de Entidades</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page" class:mounted>
<div class="layout">
<!-- Header -->
<header class="header">
<div class="header-top">
<div class="pills">
<span class="pill pill-nivel">{finfunInfo?.nivel || 'finalidad y función'}</span>
<span class="pill pill-codigo">{codigo}</span>
</div>
<div class="header-actions">
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
</div>
<h1 class="title">{finfunInfo?.desc_finfun || 'Cargando...'}</h1>
<p class="subtitle">¿Qué entidades gastan más en esto?</p>
<!-- Año + Stats -->
<div class="meta-row">
<div class="year-selector">
{#each availableYears as year}
<button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
{/each}
</div>
{#if !loading && entidadesData.length > 0}
<div class="stats">
<span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
</div>
{/if}
</div>
<!-- Slider de rango -->
{#if !loading && entidadesData.length > 0}
<div class="slider-row">
<span class="slider-label">Rango: {sliderMin}% – {sliderMax}%</span>
<span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
</div>
<div class="range-slider">
<input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
<input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
</div>
{/if}
</header>
<!-- Treemap -->
<div class="treemap-wrapper" bind:this={treemapContainer}>
{#if loading}
<div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if treemapNodes.length > 0}
<svg width={treemapWidth} height={treemapHeight}>
<!-- Group labels (subareas) -->
{#each treemapGroups as group}
{@const gw = group.x1 - group.x0}
{@const color = getSubareaColor(group.data.name)}
{@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
{#if gw > 50}
<rect
x={group.x0 + 3}
y={group.y0 + 2}
width={Math.min(label.length * 6.5 + 10, gw - 6)}
height="14"
rx="3"
fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
/>
<text
x={group.x0 + 8}
y={group.y0 + 13}
fill={isDark ? '#F5F0E8' : '#1a1a1a'}
font-size="9"
font-family="'Qanelas', var(--font-sans)"
font-weight="600"
>
{label}
</text>
{/if}
{/each}
<!-- Entity nodes -->
{#each treemapNodes as node, i}
{@const w = node.x1 - node.x0}
{@const h = node.y1 - node.y0}
{@const pct = (node.value / totalDevengado) * 100}
{@const isHovered = hoveredNode === node}
{@const area = Math.sqrt(w * h)}
{@const pctSize = Math.min(20, Math.max(9, area / 8))}
{@const nameSize = Math.min(12, Math.max(7, area / 12))}
{@const color = getSubareaColor(node.data.subarea)}
<g
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
onclick={() => goto(`/finfun/${codigo}?entidad=${node.data.id}`)}
>
<rect
width={w} height={h}
fill={color}
opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
stroke-width={isHovered ? 2 : 0.5}
rx="2"
/>
{#if w > 35 && h > 22}
{@const textColor = getTextColorForBg(color)}
{@const textColorSec = getTextColorSecondary(color)}
<foreignObject x="4" y="3" width={w - 8} height={h - 6}>
<div class="node-text">
<span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
{#if h > 40 && w > 55}
<span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/each}
</svg>
{#if hoveredNode}
{@const pct = (hoveredNode.value / totalDevengado) * 100}
<div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
<span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
<span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
<span class="tooltip-name">{hoveredNode.data.name}</span>
<span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
</div>
{/if}
{:else}
<div class="loading">No hay datos para {selectedYear}</div>
{/if}
</div>
</div>
</div>
<style>
.page {
min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 1400px;
margin: 0 auto;
padding: 80px 2rem 2rem;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header { flex-shrink: 0; margin-bottom: 1rem; }
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.pills { display: flex; gap: 0.375rem; }
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
.pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
text-decoration: none;
transition: opacity 0.15s;
}
.back-link:hover { opacity: 0.7; }
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.25rem;
line-height: 1.15;
}
.subtitle {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
.year-selector::-webkit-scrollbar { display: none; }
.year-btn {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
}
.year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
.year-btn.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: white;
font-weight: 600;
}
.stats {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.stats strong { color: var(--theme-titulo); font-weight: 600; }
.stat-sep { opacity: 0.3; }
/* Slider */
.slider-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.slider-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-titulo);
font-weight: 600;
}
.slider-info {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
}
.range-slider {
position: relative;
height: 20px;
margin-bottom: 0.5rem;
}
.range-slider input[type="range"] {
position: absolute;
width: 100%;
height: 4px;
top: 8px;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range-slider input[type="range"]:first-child {
background: var(--theme-borde);
border-radius: 2px;
}
.range-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.range-slider input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Treemap */
.treemap-wrapper {
flex: 1;
min-height: 200px;
position: relative;
background: var(--theme-surface);
border-radius: 12px;
overflow: hidden;
}
.treemap-wrapper svg { display: block; width: 100%; height: 100%; }
.loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
font-size: 0.875rem;
}
.node-text {
display: flex;
flex-direction: column;
overflow: hidden;
}
.node-pct { font-weight: 700; line-height: 1; }
.node-name { line-height: 1.2; margin-top: 1px; }
/* Tooltip */
.tooltip {
position: absolute;
pointer-events: none;
padding: 0.5rem 0.75rem;
border-radius: 8px;
z-index: 10;
max-width: 250px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
color: #f5f5f5;
display: flex;
flex-direction: column;
gap: 2px;
}
:global(html:not(.dark)) .tooltip {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
.tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
.tooltip-name { font-size: 0.8125rem; font-weight: 600; }
.tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
/* Leyenda */
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding-top: 0.5rem;
flex-shrink: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.7;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
/* Responsive */
@media (max-width: 900px) {
.layout {
padding: 70px 1rem 1rem;
height: auto;
min-height: 100vh;
overflow: visible;
}
.treemap-wrapper {
min-height: 350px;
height: 50vh;
flex: none;
}
}
@media (max-width: 640px) {
.layout { padding: 60px 0.75rem 1rem; }
.title { font-size: 1.375rem; }
.subtitle { font-size: 0.75rem; }
.meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.year-selector {
flex-wrap: wrap;
gap: 0.25rem;
}
.year-btn {
font-size: 0.625rem;
padding: 0.2rem 0.5rem;
}
.slider-row {
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.stats { font-size: 0.6875rem; }
.treemap-wrapper {
min-height: 300px;
height: 45vh;
}
.tooltip {
max-width: 200px;
font-size: 0.75rem;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 380px) {
.layout { padding: 55px 0.5rem 0.75rem; }
.title { font-size: 1.125rem; }
.treemap-wrapper {
min-height: 250px;
height: 40vh;
}
.year-btn {
font-size: 0.5625rem;
padding: 0.15rem 0.375rem;
}
}
</style>
......@@ -19,6 +19,8 @@
let mounted = $state(false);
let loading = $state(false);
let hoveredYear = $state(null);
let lockedYear = $state(null);
let activeYear = $derived(lockedYear ?? hoveredYear);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
......@@ -101,11 +103,11 @@
let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
let currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
);
// Datos del año hovereado desde datosAnuales (tiene todos los campos de la API)
let currentAnual = $derived(hoveredYear ? datosAnuales.find(d => d.gestion === hoveredYear) : null);
// Datos del año activo (fijado o hover) desde datosAnuales
let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
// KPI 1: Monto
let displayMonto = $derived.by(() => {
......@@ -156,7 +158,7 @@
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
);
// Tweens
......@@ -188,6 +190,7 @@
async function loadData() {
if (!selectedEntity) return;
lockedYear = null;
loading = true;
try {
const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
......@@ -215,12 +218,25 @@
async function loadEntidades() {
try {
const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidades-lista`);
const data = await res.json();
if (Array.isArray(data)) {
entidades = data
.filter(d => d.gestion === 0)
.map(d => ({ entidad: d.entidad, entidad_desc: d.desc_entidad }))
const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
const año = añosConDatos[0] || 2025;
const [entRes, clasRes] = await Promise.all([
fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidades-año&gestion=${año}`),
fetch('/api/entidad-clasificador')
]);
const entData = await entRes.json();
const clasData = await clasRes.json();
const nombreMap = new Map();
if (Array.isArray(clasData)) {
clasData.forEach(e => nombreMap.set(e.entidad, e.desc_entidad));
}
if (Array.isArray(entData)) {
entidades = entData
.filter(d => d.monto > 0 && d.entidad !== 0)
.map(d => ({ entidad: d.entidad, entidad_desc: nombreMap.get(d.entidad) || d.desc_entidad || `Entidad ${d.entidad}` }))
.sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
}
} catch {
......@@ -463,16 +479,36 @@
// Watch vista changes
let previousVista = 'agregado';
let skipPopstate = false;
$effect(() => {
if (mounted && vista !== previousVista) {
previousVista = vista;
if (!skipPopstate) {
const url = new URL(window.location.href);
if (vista === 'comparar') {
initComparacion();
updateCompararUrl();
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
} else {
url.searchParams.delete('vista');
url.searchParams.delete('entidadA');
url.searchParams.delete('entidadB');
}
history.pushState({ vista }, '', url.toString());
}
skipPopstate = false;
if (vista === 'comparar') initComparacion();
}
});
function handlePopstate(e) {
const params = new URL(window.location.href).searchParams;
const urlVista = params.get('vista') || 'agregado';
if (urlVista !== vista) {
skipPopstate = true;
vista = urlVista;
}
}
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
......@@ -583,6 +619,8 @@
});
</script>
<svelte:window on:popstate={handlePopstate} />
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
......@@ -658,44 +696,26 @@
</div>
<div class="explore-scroll">
<div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<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"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
</div>
<a href="/objeto/{objetoData.objeto}/entidades" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
</svg>
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/ubicacion?clas=objeto&codigo={objetoData.objeto}" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<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"/>
</svg>
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<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"/>
</svg>
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
</svg>
<a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
<span class="explore-title">Comparar partidas</span>
<span class="explore-desc">Todas las partidas entre dos momentos o entidades</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
</svg>
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todos los objetos de gasto y navegar entre ellos</span>
</a>
......@@ -716,7 +736,6 @@
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el período</span>
<span class="vigencia-sub">2005 – 2025</span>
</div>
</div>
</div>
......@@ -818,7 +837,7 @@
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
<BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
</div>
{/if}
......@@ -849,7 +868,7 @@
<h3 class="ranking-title">Concentración del gasto</h3>
<div class="ranking-summary">
<span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
<span class="ranking-summary-text">de {objetoData.desc_objeto?.toLowerCase()} lo gastan estas 3 entidades{hoveredYear ? ` en ${hoveredYear}` : ''}</span>
<span class="ranking-summary-text">de {objetoData.desc_objeto?.toLowerCase()} lo gastan estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
</div>
</div>
<div class="ranking-cards">
......@@ -857,7 +876,7 @@
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<button class="ranking-card ranking-card-link" onclick={() => {
const match = entidades.find(e => e.entidad === ent.entidad);
if (match) selectedEntity = match;
selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
}}>
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
......@@ -928,7 +947,6 @@
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntidadesA as entity}
<button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
......@@ -953,7 +971,6 @@
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntidadesB as entity}
<button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
......@@ -1088,11 +1105,13 @@
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
height: 100dvh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
overflow: hidden;
background: var(--theme-body);
padding-top: 60px;
box-sizing: border-box;
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
......@@ -1134,8 +1153,8 @@
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 80px 2rem 2.5rem;
height: 100vh;
padding: 1rem 2rem 2rem;
height: 100%;
overflow: hidden;
}
......@@ -2034,11 +2053,11 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 80px 2rem 2.5rem;
padding: 1rem 2rem 2rem;
width: 100%;
max-width: 1400px;
margin: 0 auto;
height: 100vh;
height: 100%;
overflow: hidden;
}
......
......@@ -46,7 +46,10 @@
// Años disponibles desde gestiones del objeto
let availableYears = $derived.by(() => {
if (!objetoInfo?.gestiones) return [];
return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a);
if (objetoInfo.estados?.length > 0) {
return objetoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
}
return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
});
// Formatters
......@@ -285,12 +288,6 @@
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
<a href="/objeto/{codigo}" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver
</a>
</div>
</div>
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/organismo';
export async function load({ params }) {
console.time('[SERVER] Total load organismo');
const { codigo } = params;
console.time('[SERVER] Query clas_organismos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('*')
.eq('organismo', codigo);
console.timeEnd('[SERVER] Query clas_organismos');
if (dbError || !data || data.length === 0) {
throw error(404, 'Organismo no encontrado');
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Organismo no encontrado');
const organismo = await res.json();
let padres = [];
let hijos = [];
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
const current = clasificador.find(c => c.organismo === parseInt(codigo));
if (current) {
// Padres: grupo y subgrupo (como objetos virtuales para navegación)
padres = [
{ organismo: `grupo_${current.organismo_grupo}`, desc_organismo: current.desc_organismo_grupo, nivel: 'grupo', sigla: current.sigla_organismo_grupo },
{ organismo: `subgrupo_${current.organismo_grupo}_${current.organismo_subgrupo}`, desc_organismo: current.desc_organismo_subgrupo, nivel: 'subgrupo', sigla: current.sigla_organismo_subgrupo }
];
// Hijos: otros organismos del mismo subgrupo
hijos = clasificador.filter(c =>
c.organismo_grupo === current.organismo_grupo &&
c.organismo_subgrupo === current.organismo_subgrupo &&
c.organismo !== current.organismo &&
c.organismo !== 0
).sort((a, b) => (a.desc_organismo || '').localeCompare(b.desc_organismo || ''));
}
const organismo = data[0];
// Obtener hermanos (otros organismos del mismo subgrupo)
let hermanos = [];
console.time('[SERVER] Query hermanos');
const { data: hermanosData } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('*')
.eq('organismo_grupo', organismo.organismo_grupo)
.eq('organismo_sub_grupo', organismo.organismo_sub_grupo)
.neq('organismo', codigo)
.order('organismo');
if (hermanosData) {
hermanos = hermanosData;
}
console.timeEnd('[SERVER] Query hermanos');
} catch { /* optional */ }
console.timeEnd('[SERVER] Total load organismo');
return {
organismo,
hermanos
};
return { organismo, padres, hijos };
}
......
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Spinner from '$lib/components/ui/Spinner.svelte';
import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
// Datos del servidor (reactivos)
let { data } = $props();
let organismoData = $derived(data.organismo);
let hermanos = $derived(data.hermanos);
let padres = $derived(data.padres || []);
let hijos = $derived(data.hijos);
// Estado
let mounted = $state(false);
let loading = $state(false);
let hoveredYear = $state(null);
let lockedYear = $state(null);
let activeYear = $derived(lockedYear ?? hoveredYear);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
let vista = $state('agregado'); // 'agregado' | 'comparar'
let linkCopied = $state(false);
// Inline search state
let searchVal = $state('');
let searchFocused = $state(false);
let isSearching = $state(false);
let localResults = $state([]);
let selectedIdx = $state(-1);
let debounceTimer;
let searchInputRef = $state(null);
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
async function doSearch(query) {
isSearching = true;
try {
const params = new URLSearchParams({
q: query,
per_page: '20',
is_class: 'true',
class_: 'organismo'
});
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
localResults = (data.hits || []).map(hit => {
const meta = parseMetadatos(hit.document.metadatos);
const codigo = meta.organismo;
const nombre = hit.document.texto;
const highlight = hit.highlights?.[0]?.snippet || nombre;
return { codigo, nombre, highlight };
});
selectedIdx = -1;
} catch {
localResults = [];
}
isSearching = false;
}
function handleSearchInput(e) {
searchVal = e.target.value;
clearTimeout(debounceTimer);
if (searchVal.length >= 2) {
debounceTimer = setTimeout(() => doSearch(searchVal), 200);
} else {
localResults = [];
}
}
function handleSearchKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (e.key === 'ArrowDown') {
selectedIdx = selectedIdx < localResults.length - 1 ? selectedIdx + 1 : 0;
} else {
selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : localResults.length - 1;
}
} else if (e.key === 'Enter' && localResults.length > 0) {
e.preventDefault();
const selected = localResults[selectedIdx >= 0 ? selectedIdx : 0];
if (selected) handleSearchSelect(selected);
} else if (e.key === 'Escape') {
searchVal = '';
localResults = [];
searchInputRef?.blur();
}
}
function handleSearchSelect(result) {
const url = vista === 'comparar'
? `/organismo/${result.codigo}?vista=comparar`
: `/organismo/${result.codigo}`;
goto(url);
searchVal = '';
localResults = [];
searchFocused = false;
}
function handleSearchBlur() {
setTimeout(() => { searchFocused = false; }, 150);
}
function handleClear() {
localResults = [];
searchVal = '';
}
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(entidades.length || data.nEntidades || 0);
// Datos — inicializar desde estados del server
function initEstados(obj) {
const estadosRaw = obj?.estados || [];
const seen = new Set();
const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
const hist = estados.find(d => d.gestion === 0) || null;
const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
const topPorAño = {};
anuales.forEach(d => {
topPorAño[d.gestion] = [
{ nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
{ nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
{ nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
].filter(e => e.nombre);
});
return { hist, anuales, topPorAño };
}
let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(organismoData);
let datosAnuales = $state(initAnuales);
let datosHistorico = $state(initHist);
let topEntidadesPorAño = $state(initTop);
const POBLACION = 12000000;
let organismoCodigo = $derived(organismoData.organismo);
let gastoPerCapita = $derived(
datosAnuales.map(d => {
const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
return {
año: d.gestion,
monto,
perCapita: metrica === 'percapita' ? perCapita : monto
};
})
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
let ranking = $derived(datosHistorico?.ranking ?? 0);
let nPares = $derived(datosHistorico?.n_pares ?? 0);
let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
let currentData = $derived(
activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
);
let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
// KPI 1: Monto
let displayMonto = $derived.by(() => {
if (selectedEntity) {
return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
}
return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
});
let displayMontoLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `gastados en ${currentAnual.gestion}` : `gastados ${primerAño}-${ultimoAño}`;
}
return currentAnual ? `gasto total del Estado en ${currentAnual.gestion}` : `gasto total ${primerAño}-${ultimoAño}`;
});
// KPI 2: Per cápita
let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
// KPI 3: Proporción (prop)
let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
let displayPorcentajeLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `del total de este organismo en ${currentAnual.gestion}` : 'del total de este organismo';
}
return currentAnual ? `del gasto en ${currentAnual.gestion}` : 'del gasto total';
});
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
let displayRankingLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `entre las que más gastan en ${currentAnual.gestion}` : 'entre las que más gastan';
}
return currentData ? `organismos más gastados en ${currentData.año}` : `organismos más gastados`;
});
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
{ nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
{ nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
$effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
$effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
if (!selectedEntity) return;
lockedYear = null;
loading = true;
try {
const res = await fetch(`/api/organismo-data?codigo=${organismoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
const raw = await res.json();
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion >= 2016);
topEntidadesPorAño = {};
if (datosHistorico) {
twMonto.set(datosHistorico.monto || 0);
twPerCapita.set(datosHistorico.per_capita || 0);
twPorcentaje.set(datosHistorico.prop || 0);
}
}
} catch (err) {
console.error('[loadData] Error:', err);
}
loading = false;
}
async function loadEntidades() {
try {
const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
const año = añosConDatos[0] || 2025;
const entRes = await fetch(`/api/organismo-data?codigo=${organismoCodigo}&tipo=entidades-año&gestion=${año}`);
const entData = await entRes.json();
if (Array.isArray(entData)) {
entidades = entData
.filter(d => d.monto > 0 && d.entidad !== 0)
.map(d => ({ entidad: d.entidad, entidad_desc: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}` }))
.sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
}
} catch {
entidades = [];
}
}
// ══════════════════════════════════════════════════════════════
// COMPARACION
// ══════════════════════════════════════════════════════════════
const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
let hoveredYearComparar = $state(null);
let loadingComparacion = $state(false);
let isDefaultSelection = $state(false);
let alertDefaultDismissed = $state(false);
let alertRangosDismissed = $state(false);
let entidadCompararA = $state(null);
let entidadCompararB = $state(null);
let datosCompararA = $state({ anual: [], historico: null });
let datosCompararB = $state({ anual: [], historico: null });
let dropdownAOpen = $state(false);
let dropdownBOpen = $state(false);
let searchA = $state('');
let searchB = $state('');
let entityLimitA = $state(30);
let entityLimitB = $state(30);
let filteredEntidadesA = $derived.by(() => {
if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
const q = searchA.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitA);
});
let filteredEntidadesB = $derived.by(() => {
if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
const q = searchB.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitB);
});
async function loadDatosEntidad(entidad) {
try {
const res = await fetch(`/api/organismo-data?codigo=${organismoCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
const raw = await res.json();
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
}
} catch {}
return { anual: [], historico: null };
}
async function initComparacion() {
if (entidadCompararA || entidadCompararB) return;
if (selectedEntity) {
entidadCompararA = selectedEntity;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(selectedEntity);
loadingComparacion = false;
isDefaultSelection = false;
} else if (topEntidadesTotal.length >= 2) {
const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
if (top1 && top2) {
isDefaultSelection = true;
loadingComparacion = true;
entidadCompararA = top1;
datosCompararA = await loadDatosEntidad(top1);
entidadCompararB = top2;
datosCompararB = await loadDatosEntidad(top2);
loadingComparacion = false;
}
}
}
async function selectEntidadA(entidad) {
entidadCompararA = entidad;
dropdownAOpen = false;
searchA = '';
entityLimitA = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
async function selectEntidadB(entidad) {
entidadCompararB = entidad;
dropdownBOpen = false;
searchB = '';
entityLimitB = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararB = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
function handleEntityScroll(event, type) {
const el = event.target;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
}
}
let rangoAños = $state('todo');
let añosComunesTotales = $derived.by(() => {
const añosA = datosCompararA.anual.map(d => d.gestion);
const añosB = datosCompararB.anual.map(d => d.gestion);
if (añosA.length && añosB.length) {
const setB = new Set(añosB);
return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
}
if (añosA.length) return [...añosA].sort((a, b) => a - b);
if (añosB.length) return [...añosB].sort((a, b) => a - b);
return [];
});
let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
let rangoInfoA = $derived.by(() => {
if (!datosCompararA.anual.length) return null;
const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let rangoInfoB = $derived.by(() => {
if (!datosCompararB.anual.length) return null;
const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let tienenRangosDiferentes = $derived.by(() => {
if (!rangoInfoA || !rangoInfoB) return false;
return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
});
let añosComparacion = $derived.by(() => {
if (rangoAños === 'todo') return añosComunesTotales;
return añosComunesTotales.slice(-parseInt(rangoAños));
});
let datosGraficoA = $derived.by(() => {
const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
let datosGraficoB = $derived.by(() => {
const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
function valComparar(d) {
return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
}
let maxComparacion = $derived.by(() => {
const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
return Math.max(...all, 1) * 1.1;
});
let displayPeriodoComparar = $derived.by(() => {
if (hoveredYearComparar) return hoveredYearComparar;
if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
return 'Selecciona entidades';
});
let displayPropA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
return datosCompararA.historico?.prop || 0;
});
let displayPropB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
return datosCompararB.historico?.prop || 0;
});
let displayRankingA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
return datosCompararA.historico?.ranking || '-';
});
let displayRankingB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
return datosCompararB.historico?.ranking || '-';
});
let displayNEntidadesA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
return datosCompararA.historico?.n_entidades || '-';
});
let displayNEntidadesB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
return datosCompararB.historico?.n_entidades || '-';
});
let isDarkMode = $state(false);
$effect(() => {
if (typeof document !== 'undefined') {
const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
checkDark();
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
});
let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
function toNumber(v) { return typeof v === 'number' ? v : 0; }
$effect(() => {
const year = hoveredYearComparar;
const idx = year ? añosComparacion.indexOf(year) : -1;
twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
});
function updateCompararUrl() {
if (!mounted) return;
const url = new URL($page.url);
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
else url.searchParams.delete('entidadA');
if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
else url.searchParams.delete('entidadB');
goto(url.toString(), { replaceState: true, noScroll: true });
}
let previousVista = 'agregado';
let skipPopstate = false;
$effect(() => {
if (mounted && vista !== previousVista) {
previousVista = vista;
if (!skipPopstate) {
const url = new URL(window.location.href);
if (vista === 'comparar') {
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
} else {
url.searchParams.delete('vista');
url.searchParams.delete('entidadA');
url.searchParams.delete('entidadB');
}
history.pushState({ vista }, '', url.toString());
}
skipPopstate = false;
if (vista === 'comparar') initComparacion();
}
});
function handlePopstate(e) {
const params = new URL(window.location.href).searchParams;
const urlVista = params.get('vista') || 'agregado';
if (urlVista !== vista) {
skipPopstate = true;
vista = urlVista;
}
}
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
return m?.toLocaleString('es-BO') || '0';
}
// ══════════════════════════════════════════════════════════════
onMount(() => {
setTimeout(() => { mounted = true; }, 50);
mounted = true;
loadEntidades().then(async () => {
const urlVista = $page.url.searchParams.get('vista');
const urlEntidad = $page.url.searchParams.get('entidad');
const urlEntidadA = $page.url.searchParams.get('entidadA');
const urlEntidadB = $page.url.searchParams.get('entidadB');
if (urlVista === 'comparar') {
vista = 'comparar';
if (urlEntidadA) {
const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
if (entA) {
entidadCompararA = entA;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entA);
}
}
if (urlEntidadB) {
const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
if (entB) {
entidadCompararB = entB;
datosCompararB = await loadDatosEntidad(entB);
}
}
loadingComparacion = false;
if (!urlEntidadA && !urlEntidadB) initComparacion();
} else if (urlEntidad && entidades.length > 0) {
const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
if (ent) selectedEntity = ent;
}
});
});
// Recargar cuando cambia el organismo (navegacion entre organismos)
let prevCodigo = null;
$effect(() => {
const code = organismoCodigo;
if (prevCodigo !== null && code !== prevCodigo) {
entidadCompararA = null;
entidadCompararB = null;
datosCompararA = { anual: [], historico: null };
datosCompararB = { anual: [], historico: null };
const { hist, anuales, topPorAño } = initEstados(organismoData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
const prevEntity = selectedEntity;
loadEntidades().then(() => {
if (prevEntity) {
const match = entidades.find(e => e.entidad === prevEntity.entidad);
if (match) {
selectedEntity = match;
loadData();
} else {
selectedEntity = null;
}
}
});
}
prevCodigo = code;
});
// Recargar cuando cambia la entidad seleccionada
let prevEntity = null;
$effect(() => {
const ent = selectedEntity;
if (ent !== prevEntity) {
prevEntity = ent;
if (ent) {
loadData();
} else if (prevCodigo) {
const { hist, anuales, topPorAño } = initEstados(organismoData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
}
if (mounted) {
const url = new URL($page.url);
if (ent) {
url.searchParams.set('entidad', ent.entidad);
} else {
url.searchParams.delete('entidad');
}
goto(url.toString(), { replaceState: true, noScroll: true });
}
}
});
</script>
<svelte:window on:popstate={handlePopstate} />
<svelte:head>
<title>{organismoData.organismo_desc_organismo || organismoData.organismo} | Clasificador de Organismos</title>
<title>{organismoData?.desc_organismo || `Organismo ${organismoData?.organismo}`} | Presupuesto Abierto</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page" class:mounted>
<!-- Link a clasificadores -->
<nav class="page-nav">
<a href="/clasificadores/organismos" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
{#if vista === 'agregado'}
<div class="dashboard-layout">
<!-- CONTEXTO IZQUIERDO -->
<aside class="dashboard-context">
<!-- Sidebar -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
<!-- Mobile toggle -->
<button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
</button>
<div class="context-card">
<h2 class="context-main-title">Contexto</h2>
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<div class="inline-search" class:focused={searchFocused}>
<svg class="inline-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
<input
bind:this={searchInputRef}
type="text"
class="inline-search-input"
placeholder="Buscar organismo..."
value={searchVal}
oninput={handleSearchInput}
onfocus={() => searchFocused = true}
onblur={handleSearchBlur}
onkeydown={handleSearchKeydown}
/>
{#if searchVal}
<button class="inline-search-clear" onclick={handleClear}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{/if}
{#if localResults.length > 0 && searchFocused}
<div class="inline-search-results">
{#each localResults as result, i}
<button class="inline-search-item" class:selected={selectedIdx === i} onclick={() => handleSearchSelect(result)}>
<span class="inline-search-code">{result.codigo}</span>
<span class="inline-search-name">{@html result.highlight}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
<!-- Info del grupo y subgrupo -->
<!-- Navegación jerárquica: grupo → subgrupo → organismo -->
{#if padres.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Ubicación</h3>
<div class="ubicacion-info">
<div class="ubicacion-item">
<span class="ubicacion-label">Grupo {organismoData.organismo_grupo}</span>
<span class="ubicacion-value">{organismoData.organismo_desc_grupo}</span>
<h3 class="context-subtitle">Navegación</h3>
<div class="tree">
{#each padres as padre, i}
<div class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_organismo}">
<span class="tree-dot"></span>
<span class="tree-name">{padre.desc_organismo}</span>
</div>
<div class="ubicacion-item">
<span class="ubicacion-label">Subgrupo {organismoData.organismo_sub_grupo}</span>
<span class="ubicacion-value">{organismoData.organismo_desc_sub_grupo}</span>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{organismoData.desc_organismo}">
<span class="tree-dot"></span>
<span class="tree-name">{organismoData.desc_organismo}</span>
</div>
</div>
</div>
<!-- Hermanos -->
{#if hermanos.length > 0}
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<h3 class="context-subtitle">Mismo subgrupo ({hermanos.length})</h3>
<p class="context-hint">Otros organismos del mismo subgrupo.</p>
<div class="hermanos-list">
{#each hermanos.slice(0, 10) as hermano}
<a href="/organismo/{hermano.organismo}" class="hermano-link">
<span class="hermano-code">{hermano.organismo}</span>
<span class="hermano-name">{hermano.organismo_desc_organismo}</span>
<div class="explore-header">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<span class="explore-count">5 →</span>
</div>
<div class="explore-scroll">
<div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">Quien gasta mas con financiamiento de {organismoData.desc_organismo?.toLowerCase()}?</span>
</div>
<a href="/organismo/{organismoData.organismo}/entidades" class="explore-card">
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades financiadas por {organismoData.desc_organismo?.toLowerCase()}</span>
</a>
<a href="/clasificadores/organismos" class="explore-card">
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuanto pesa {organismoData.desc_organismo?.toLowerCase()} respecto a otros organismos</span>
</a>
<a href="/clasificadores/organismos?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
<span class="explore-title">Comparar organismos</span>
<span class="explore-desc">Todos los organismos entre dos momentos o entidades</span>
</a>
<a href="/clasificadores/organismos" class="explore-card">
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todos los organismos financiadores y navegar entre ellos</span>
</a>
</div>
</div>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el periodo</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Compartir -->
<div class="context-section">
<h3 class="context-subtitle">Compartir</h3>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
</button>
</div>
</div>
</aside>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">Organismo</span>
<span class="pill pill-codigo">{organismoData.organismo}</span>
</div>
</div>
<h2 class="main-title-name">{organismoData.desc_organismo}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
{#if selectedEntity}
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Todo el Estado
</button>
{/if}
</div>
<div class="chart-header">
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<span class="chart-period">· {displayPeriodo}</span>
</span>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else if gastoPerCapita.length === 0 && selectedEntity}
<div class="chart-no-data">
<p>Esta entidad no tiene registros en {organismoData.desc_organismo?.toLowerCase()} desde 2016</p>
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver a Todo el Estado
</button>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<div class="ranking-header">
<h3 class="ranking-title">Concentracion del gasto</h3>
<div class="ranking-summary">
<span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
<span class="ranking-summary-text">de {organismoData.desc_organismo?.toLowerCase()} lo gastan estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
</div>
</div>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<button class="ranking-card ranking-card-link" onclick={() => {
const match = entidades.find(e => e.entidad === ent.entidad);
selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
}}>
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
<svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
{/each}
{#if hermanos.length > 10}
<span class="hermanos-more">+{hermanos.length - 10} más</span>
</div>
</div>
{/if}
</main>
</div>
{:else}
<!-- VISTA COMPARAR -->
<main class="main-comparar">
<div class="chart-card-comparar">
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">Organismo</span>
<span class="pill pill-codigo">{organismoData.organismo}</span>
</div>
<VistaToggle bind:vista />
</div>
<h2 class="main-title-name">{organismoData.desc_organismo}</h2>
<div class="comparador-selectors">
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorA}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
<span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if dropdownAOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntidadesA as entity}
<button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</aside>
</div>
<span class="vs">vs</span>
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorB}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
<span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if dropdownBOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntidadesB as entity}
<button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
<div class="comparador-hint">
<span>Mostrando las dos entidades con mayor gasto. Puedes cambiar la seleccion arriba.</span>
<button class="alert-close" onclick={() => alertDefaultDismissed = true}>x</button>
</div>
{/if}
{#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
<div class="comparador-warning">
<span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} anos en comun.</span>
<button class="alert-close" onclick={() => alertRangosDismissed = true}>x</button>
</div>
{/if}
{#if loadingComparacion}
<div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if !entidadCompararA && !entidadCompararB}
<div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
{:else if añosComparacion.length === 0}
<div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
{:else}
<div class="chart-header">
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<span class="chart-period">· {displayPeriodoComparar}</span>
</span>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
<div class="chart-comparar-wrapper">
<div class="chart-comparar-inner">
<span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
<span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
<span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
<div class="grid-line-comparar" style="bottom: 100%"></div>
<div class="grid-line-comparar" style="bottom: 50%"></div>
<div class="grid-line-comparar" style="bottom: 0%"></div>
<div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
{#each añosComparacion as año, i}
<div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
<div class="bars-pair">
<div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
<div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
</div>
</div>
{/each}
</div>
</div>
<div class="x-axis-comparar">
{#each añosComparacion as año, i}
{@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
<span class="x-label-comparar" class:visible={show}>{año}</span>
{/each}
</div>
</div>
<div class="chart-legend">
{#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
{#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
</div>
{/if}
</div>
{#if soloUnaEntidad}
<div class="comparador-hint comparador-hint-select">
<span>Selecciona la segunda entidad para ver la comparacion completa.</span>
</div>
{/if}
{#if entidadCompararA && entidadCompararB}
<div class="kpis-card-comparar">
<div class="tabla-header">
<span class="tabla-label"></span>
<span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
<span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Gasto</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Per capita</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Peso</span>
<span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Ranking</span>
<span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
<span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
</div>
</div>
{/if}
</main>
{/if}
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
height: 100dvh;
width: 100%;
max-width: 100%;
overflow: hidden;
background: var(--theme-body);
padding-top: 60px;
box-sizing: border-box;
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 1rem 2rem 2rem;
height: 100%;
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
INLINE SEARCH
═══════════════════════════════════════════════════════════════ */
.inline-search {
position: relative;
display: flex;
align-items: center;
gap: 0.375rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 8px;
padding: 0.375rem 0.625rem;
transition: border-color 0.2s;
}
.inline-search.focused { border-color: var(--theme-accent); }
.inline-search-icon {
flex-shrink: 0;
opacity: 0.4;
color: var(--theme-texto);
}
.inline-search-input {
flex: 1;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
min-width: 0;
}
.inline-search-input::placeholder {
color: var(--theme-texto);
opacity: 0.4;
}
.inline-search-clear {
flex-shrink: 0;
padding: 2px;
background: none;
border: none;
color: var(--theme-texto);
opacity: 0.5;
cursor: pointer;
display: flex;
align-items: center;
}
.inline-search-clear:hover { opacity: 1; }
.inline-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
max-height: 240px;
overflow-y: auto;
z-index: 100;
}
.inline-search-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.inline-search-item:hover,
.inline-search-item.selected {
background: rgba(201, 167, 81, 0.1);
}
.inline-search-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
min-width: 2rem;
flex-shrink: 0;
}
.inline-search-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════════════════════════
UBICACION INFO (grupo/subgrupo)
═══════════════════════════════════════════════════════════════ */
.ubicacion-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ubicacion-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.625rem;
background: var(--theme-borde);
border-radius: 6px;
}
.ubicacion-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.6;
text-transform: uppercase;
}
.ubicacion-value {
font-size: 0.75rem;
color: var(--theme-titulo);
line-height: 1.3;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.explore-count {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
opacity: 0.7;
animation: nudgeRight 1.5s ease-in-out infinite;
}
@keyframes nudgeRight {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-scroll {
-webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
mask-image: linear-gradient(to right, black 85%, transparent 100%);
}
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-titulo);
}
.chart-no-data {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
font-size: 0.8125rem;
text-align: center;
}
.chart-no-data p {
margin: 0;
}
.volver-agregado {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-accent);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.volver-agregado:hover { opacity: 0.7; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.copy-link-btn svg { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.15;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.metrica-toggle {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
padding: 0.125rem 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.metrica-toggle:hover {
background: rgba(201, 167, 81, 0.12);
border-color: rgba(201, 167, 81, 0.3);
color: var(--theme-accent);
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
text-align: center;
}
.kpi-value {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
<!-- CONTENIDO PRINCIPAL -->
<main class="dashboard-main">
<div class="main-content">
<!-- Header -->
<div class="main-header">
<div class="main-title-pills">
<span class="pill pill-codigo">{organismoData.organismo}</span>
</div>
<h1 class="main-title-name">{organismoData.organismo_desc_organismo}</h1>
{#if organismoData.organismo_sigla_organismo && organismoData.organismo_sigla_organismo !== 'NC'}
<p class="main-sigla">{organismoData.organismo_sigla_organismo}</p>
{/if}
</div>
.ranking-card {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: transparent;
text-align: left;
}
<!-- Info cards -->
<div class="info-grid">
<div class="info-card">
<span class="info-label">Grupo</span>
<span class="info-value">{organismoData.organismo_grupo}</span>
<span class="info-desc">{organismoData.organismo_desc_grupo}</span>
</div>
.ranking-card-link {
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
<div class="info-card">
<span class="info-label">Subgrupo</span>
<span class="info-value">{organismoData.organismo_sub_grupo}</span>
<span class="info-desc">{organismoData.organismo_desc_sub_grupo}</span>
</div>
.ranking-card-link:hover {
background: rgba(201, 167, 81, 0.05);
}
{#if organismoData.gestiones}
<div class="info-card">
<span class="info-label">Gestiones</span>
<span class="info-value">{organismoData.n_gestiones || ''} años</span>
<span class="info-desc">{organismoData.gestiones}</span>
</div>
{/if}
</div>
</div>
</main>
</div>
</div>
.ranking-card-link:hover .ranking-name {
color: var(--theme-accent);
border-bottom-style: solid;
}
<style>
/* ═══════════════════════════════════════════════════════════════
BASE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
padding: 1rem;
.ranking-arrow {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.4s ease, transform 0.4s ease;
color: var(--theme-accent);
transition: opacity 0.15s, transform 0.15s;
flex-shrink: 0;
}
.page.mounted {
.ranking-card-link:hover .ranking-arrow {
opacity: 1;
transform: translateY(0);
transform: translateX(2px);
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
max-width: 1400px;
margin: 0 auto 1.5rem;
.ranking-header {
margin-bottom: 0.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary, #888);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
.ranking-summary {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin-top: 0.125rem;
}
.ranking-summary-pct {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
font-weight: 700;
color: var(--theme-titulo);
}
.ranking-summary-text {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
font-size: 0.8125rem;
color: var(--theme-titulo);
border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
}
.back-link:hover {
color: var(--text-primary, #fff);
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
opacity: 0.7;
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT
MOBILE SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
.mobile-sidebar-toggle {
display: none;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
gap: 0.5rem;
height: auto;
overflow: visible;
padding: 1.5rem 1rem 2rem;
align-content: center;
min-height: calc(100vh - 4rem);
}
.dashboard-context {
order: 2;
.dashboard-main {
overflow: visible;
}
.chart-protagonista {
flex: none;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
position: sticky;
top: 5rem;
height: fit-content;
.ranking-section {
flex: none;
}
.mobile-sidebar-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
color: var(--theme-titulo);
font-family: 'Qanelas', var(--font-sans);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.25rem;
width: 100%;
}
.mobile-sidebar-toggle:hover {
background: var(--theme-surface-hover);
}
.mobile-sidebar-toggle svg {
color: var(--theme-texto);
opacity: 0.6;
}
.dashboard-context .context-card {
display: none;
}
.dashboard-context.mobile-open .context-card {
display: flex;
animation: slideDown 0.25s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.context-card {
background: var(--card-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 1.5rem;
height: auto;
overflow-y: visible;
}
.context-main-title {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #666);
margin-bottom: 1.5rem;
.kpis {
grid-template-columns: repeat(2, 1fr);
}
}
.context-section {
margin-bottom: 1.5rem;
@media (max-width: 480px) {
.dashboard-layout {
padding: 0.5rem;
}
.context-subtitle {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #aaa);
margin-bottom: 0.75rem;
.chart-protagonista,
.ranking-section,
.context-card {
padding: 0.5rem;
border-radius: 8px;
}
.context-hint {
.main-title-name {
font-size: 1.375rem;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.kpi-value {
font-size: 0.8125rem;
}
.kpi-label {
font-size: 0.5625rem;
}
.explore-scroll {
gap: 6px;
}
.explore-card {
width: 100px;
height: 80px;
}
.explore-title {
font-size: 0.625rem;
}
.explore-desc {
font-size: 0.5rem;
}
.ranking-title {
font-size: 0.875rem;
}
.ranking-name {
font-size: 0.6875rem;
}
.ranking-pct {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
margin-bottom: 1rem;
}
.context-separator {
height: 1px;
background: var(--border-color, rgba(255, 255, 255, 0.08));
margin: 1.5rem 0;
.tree-name {
max-width: 150px;
}
}
/* ═══════════════════════════════════════════════════════════════
UBICACION INFO
DARK MODE
═══════════════════════════════════════════════════════════════ */
.ubicacion-info {
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.5);
}
/* ═══════════════════════════════════════════════════════════════
COMPARAR VIEW
═══════════════════════════════════════════════════════════════ */
.main-comparar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 2rem 2rem;
width: 100%;
max-width: 1400px;
margin: 0 auto;
height: 100%;
overflow: hidden;
}
.ubicacion-item {
.chart-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
flex: 1;
min-height: 0;
}
.comparador-selectors {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
.selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
.relative { position: relative; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selector-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 8px;
font-size: 0.8125rem;
color: var(--theme-titulo);
cursor: pointer;
transition: border-color 0.2s;
}
.selector-btn:hover { border-color: var(--theme-accent); }
.comparador-btn { width: 100%; justify-content: space-between; }
.comparador-dropdown-container { position: relative; }
.ubicacion-label {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--text-tertiary, #666);
text-transform: uppercase;
.dropdown-panel.comparador-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
min-width: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.ubicacion-value {
font-size: 0.85rem;
color: var(--text-primary, #fff);
.dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
}
.search-input:focus { border-color: var(--theme-accent); }
/* ═══════════════════════════════════════════════════════════════
HERMANOS LIST
═══════════════════════════════════════════════════════════════ */
.hermanos-list {
.dropdown-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover { background: var(--theme-surface-hover); }
.dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
.dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
.dropdown-list { max-height: 240px; overflow-y: auto; }
.comparador-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed var(--theme-borde);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
.hermano-link {
.comparador-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
text-decoration: none;
color: var(--text-secondary, #aaa);
font-size: 0.8rem;
transition: all 0.2s ease;
padding: 0.75rem 1rem;
background: rgba(107, 159, 212, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #6B9FD4;
}
.comparador-hint span { flex: 1; }
.comparador-hint-select { margin-top: 0.75rem; }
.hermano-link:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-primary, #fff);
.comparador-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(230, 176, 75, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #C9A751;
}
.comparador-warning span { flex: 1; }
.hermano-code {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
opacity: 0.6;
.alert-close {
flex-shrink: 0;
min-width: 2.5rem;
padding: 0.25rem;
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
border-radius: 4px;
}
.alert-close:hover { opacity: 1; }
.hermano-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.chart-comparar-wrapper {
position: relative;
width: 100%;
flex: 1;
min-height: 180px;
}
.hermanos-more {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
padding: 0.5rem 0.75rem;
.chart-comparar-inner {
position: absolute;
top: 10px;
bottom: 30px;
left: 50px;
right: 10px;
}
/* ═══════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
min-width: 0;
.y-label-comparar {
position: absolute;
left: -50px;
width: 45px;
text-align: right;
transform: translateY(50%);
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
white-space: nowrap;
}
.main-content {
background: var(--card-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 2rem;
.grid-line-comparar {
position: absolute;
left: 0;
right: 0;
border-top: 0.5px dotted var(--theme-texto);
opacity: 0.15;
}
.main-header {
margin-bottom: 2rem;
.bars-comparar {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: 3px;
}
.main-title-pills {
.bar-group-comparar {
flex: 1;
height: 100%;
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: flex-end;
justify-content: center;
cursor: pointer;
}
.pill {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
.bars-pair {
display: flex;
align-items: flex-end;
gap: 2px;
height: 100%;
width: 100%;
justify-content: center;
}
.pill-codigo {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-secondary, #aaa);
font-family: 'DM Mono', monospace;
.bar-comparar {
flex: 1;
max-width: 24px;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: opacity 0.15s;
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
color: var(--text-primary, #fff);
line-height: 1.2;
margin: 0;
.bars-comparar:hover .bar-comparar { opacity: 0.35; }
.bar-group-comparar.active .bar-comparar { opacity: 1; }
.x-axis-comparar {
position: absolute;
bottom: 0;
left: 50px;
right: 10px;
height: 30px;
display: flex;
align-items: center;
}
.main-sigla {
.x-label-comparar {
flex: 1;
text-align: center;
font-family: 'DM Mono', monospace;
font-size: 0.9rem;
color: var(--text-tertiary, #666);
margin-top: 0.5rem;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s;
}
.x-label-comparar.visible { opacity: 1; }
/* ═══════════════════════════════════════════════════════════════
INFO GRID
═══════════════════════════════════════════════════════════════ */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
.chart-legend {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.info-card {
.legend-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--theme-texto);
}
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
.kpis-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
padding: 0.75rem 1.25rem;
}
.info-label {
font-size: 0.75rem;
.tabla-header {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--theme-borde);
margin-bottom: 0.25rem;
}
.tabla-header .tabla-val {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #666);
text-align: center;
}
.info-value {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
color: var(--text-primary, #fff);
.tabla-entidad {
font-size: 0.6875rem;
line-height: 1.3;
word-break: break-word;
}
.info-desc {
font-size: 0.8rem;
color: var(--text-secondary, #aaa);
line-height: 1.4;
.tabla-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding: 0.1875rem 0;
}
.tabla-label {
font-size: 0.6875rem;
font-weight: 500;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tabla-val {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
text-align: center;
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.main-comparar { padding: 0.75rem; }
.chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
.comparador-selectors { flex-direction: column; gap: 0.75rem; }
.vs { display: none; }
}
</style>
......
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import * as d3 from 'd3';
let codigo = $derived($page.params.codigo);
// Estado
let loading = $state(true);
let mounted = $state(false);
let organismoInfo = $state(null);
let entidadesData = $state([]);
let selectedYear = $state(null);
let linkCopied = $state(false);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
let entidadClasificador = $state({}); // Map entidad -> { desc_subarea, sigla_subarea, desc_area, ... }
let subareas = $state([]); // Lista unica de subareas con color
let sliderMin = $state(0);
let sliderMax = $state(100);
// Filtrar entidades por rango de % acumulado
let filteredEntidades = $derived.by(() => {
if (sliderMin === 0 && sliderMax === 100) return entidadesData;
let acum = 0;
return entidadesData.filter(d => {
const pctBefore = (acum / totalDevengado) * 100;
acum += d.monto;
const pctAfter = (acum / totalDevengado) * 100;
return pctAfter > sliderMin && pctBefore < sliderMax;
});
});
let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
let filteredCount = $derived(filteredEntidades.length);
// Treemap
let treemapContainer = $state(null);
let treemapWidth = $state(0);
let treemapHeight = $state(0);
let treemapNodes = $state([]);
let treemapGroups = $state([]); // Nodos padre (subareas)
// Anos disponibles desde gestiones del organismo
let availableYears = $derived.by(() => {
if (!organismoInfo?.gestiones) return [];
if (organismoInfo.estados?.length > 0) {
return organismoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
}
return organismoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
});
// Formatters
function formatMonto(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
return v?.toLocaleString('es-BO') || '0';
}
function formatCompact(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
return v?.toFixed(0) || '0';
}
// Colores por subarea
let isDark = $state(false);
const SUBAREA_COLORS = [
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
];
function getSubareaColor(subarea) {
const idx = subareas.indexOf(subarea);
return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
}
// Contraste de texto segun luminosidad del fondo (WCAG)
function getTextColorForBg(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.95)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
function getTextColorSecondary(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.88)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
}
// Cargar clasificador de entidades
async function loadClasificador() {
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (Array.isArray(data)) {
const map = {};
data.forEach(d => { map[d.entidad] = d; });
entidadClasificador = map;
}
} catch {}
}
// Cargar info del organismo desde API
async function loadOrganismo() {
try {
const res = await fetch(`/api/organismo-data?codigo=${codigo}&tipo=clasificador`);
const data = await res.json();
if (data && !data.error) organismoInfo = data;
} catch {}
}
// Cargar entidades desde API
async function loadEntidades() {
if (!selectedYear || !codigo) return;
loading = true;
try {
const res = await fetch(`/api/organismo-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
const raw = await res.json();
if (Array.isArray(raw)) {
// Deduplicar por entidad
const map = new Map();
raw.forEach(d => {
if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
map.set(d.entidad, d);
}
});
entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
// Organismo entidades endpoint returns entidad_desc directly
entidadesData = entidadesData.map(d => ({
...d,
desc_entidad: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}`,
desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
}));
// Extraer subareas unicas ordenadas por monto total
const subareaMontos = {};
entidadesData.forEach(d => {
subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
});
subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
buildTreemap();
}
} catch (err) {
console.error('[Entidades] Error:', err);
}
loading = false;
}
function buildTreemap() {
if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
treemapNodes = [];
treemapGroups = [];
return;
}
// Agrupar por subarea
const groups = {};
filteredEntidades.forEach(d => {
const sub = d.desc_subarea || 'Otros';
if (!groups[sub]) groups[sub] = [];
groups[sub].push({
id: d.entidad,
name: d.desc_entidad || `Entidad ${d.entidad}`,
value: d.monto,
ranking: d.ranking,
per_capita: d.per_capita,
prop: d.prop,
subarea: sub
});
});
const hierarchy = d3.hierarchy({
name: 'root',
children: Object.entries(groups).map(([subarea, children]) => ({
name: subarea,
children
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(3)
.paddingInner(1)
.paddingTop(18)
.round(true)(hierarchy);
treemapNodes = hierarchy.leaves() || [];
treemapGroups = hierarchy.children || [];
}
function changeYear(year) {
selectedYear = year;
goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
loadEntidades();
}
$effect(() => {
if (treemapContainer) {
const measure = () => {
const w = treemapContainer.clientWidth;
const h = treemapContainer.clientHeight;
if (w > 0 && h > 0) {
treemapWidth = w;
treemapHeight = h;
buildTreemap();
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(treemapContainer);
return () => observer.disconnect();
}
});
onMount(async () => {
mounted = true;
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
await Promise.all([loadOrganismo(), loadClasificador()]);
const yearParam = $page.url.searchParams.get('gestion');
if (yearParam) {
const y = parseInt(yearParam);
selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
} else {
selectedYear = availableYears[0] || 2025;
}
if (treemapContainer) {
treemapWidth = treemapContainer.clientWidth;
treemapHeight = treemapContainer.clientHeight;
}
await loadEntidades();
});
</script>
<svelte:head>
<title>{organismoInfo?.desc_organismo || `Organismo ${codigo}`} - Ranking de Entidades</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page" class:mounted>
<div class="layout">
<!-- Header -->
<header class="header">
<div class="header-top">
<div class="pills">
<span class="pill pill-nivel">organismo</span>
<span class="pill pill-codigo">{codigo}</span>
</div>
<div class="header-actions">
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
</div>
<h1 class="title">{organismoInfo?.desc_organismo || 'Cargando...'}</h1>
<p class="subtitle">Que entidades gastan mas con financiamiento de este organismo?</p>
<!-- Ano + Stats -->
<div class="meta-row">
<div class="year-selector">
{#each availableYears as year}
<button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
{/each}
</div>
{#if !loading && entidadesData.length > 0}
<div class="stats">
<span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
</div>
{/if}
</div>
<!-- Slider de rango -->
{#if !loading && entidadesData.length > 0}
<div class="slider-row">
<span class="slider-label">Rango: {sliderMin}% - {sliderMax}%</span>
<span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
</div>
<div class="range-slider">
<input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
<input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
</div>
{/if}
</header>
<!-- Treemap -->
<div class="treemap-wrapper" bind:this={treemapContainer}>
{#if loading}
<div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if treemapNodes.length > 0}
<svg width={treemapWidth} height={treemapHeight}>
<!-- Group labels (subareas) -->
{#each treemapGroups as group}
{@const gw = group.x1 - group.x0}
{@const color = getSubareaColor(group.data.name)}
{@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
{#if gw > 50}
<rect
x={group.x0 + 3}
y={group.y0 + 2}
width={Math.min(label.length * 6.5 + 10, gw - 6)}
height="14"
rx="3"
fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
/>
<text
x={group.x0 + 8}
y={group.y0 + 13}
fill={isDark ? '#F5F0E8' : '#1a1a1a'}
font-size="9"
font-family="'Qanelas', var(--font-sans)"
font-weight="600"
>
{label}
</text>
{/if}
{/each}
<!-- Entity nodes -->
{#each treemapNodes as node, i}
{@const w = node.x1 - node.x0}
{@const h = node.y1 - node.y0}
{@const pct = (node.value / totalDevengado) * 100}
{@const isHovered = hoveredNode === node}
{@const area = Math.sqrt(w * h)}
{@const pctSize = Math.min(20, Math.max(9, area / 8))}
{@const nameSize = Math.min(12, Math.max(7, area / 12))}
{@const color = getSubareaColor(node.data.subarea)}
<g
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
onclick={() => goto(`/organismo/${codigo}?entidad=${node.data.id}`)}
>
<rect
width={w} height={h}
fill={color}
opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
stroke-width={isHovered ? 2 : 0.5}
rx="2"
/>
{#if w > 35 && h > 22}
{@const textColor = getTextColorForBg(color)}
{@const textColorSec = getTextColorSecondary(color)}
<foreignObject x="4" y="3" width={w - 8} height={h - 6}>
<div class="node-text">
<span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
{#if h > 40 && w > 55}
<span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/each}
</svg>
{#if hoveredNode}
{@const pct = (hoveredNode.value / totalDevengado) * 100}
<div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
<span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
<span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
<span class="tooltip-name">{hoveredNode.data.name}</span>
<span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
</div>
{/if}
{:else}
<div class="loading">No hay datos para {selectedYear}</div>
{/if}
</div>
</div>
</div>
<style>
.page {
min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 1400px;
margin: 0 auto;
padding: 80px 2rem 2rem;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header { flex-shrink: 0; margin-bottom: 1rem; }
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.pills { display: flex; gap: 0.375rem; }
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
.pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.25rem;
line-height: 1.15;
}
.subtitle {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
.year-selector::-webkit-scrollbar { display: none; }
.year-btn {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
}
.year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
.year-btn.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: white;
font-weight: 600;
}
.stats {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.stats strong { color: var(--theme-titulo); font-weight: 600; }
/* Slider */
.slider-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.slider-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-titulo);
font-weight: 600;
}
.slider-info {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
}
.range-slider {
position: relative;
height: 20px;
margin-bottom: 0.5rem;
}
.range-slider input[type="range"] {
position: absolute;
width: 100%;
height: 4px;
top: 8px;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range-slider input[type="range"]:first-child {
background: var(--theme-borde);
border-radius: 2px;
}
.range-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.range-slider input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Treemap */
.treemap-wrapper {
flex: 1;
min-height: 200px;
position: relative;
background: var(--theme-surface);
border-radius: 12px;
overflow: hidden;
}
.treemap-wrapper svg { display: block; width: 100%; height: 100%; }
.loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
font-size: 0.875rem;
}
.node-text {
display: flex;
flex-direction: column;
overflow: hidden;
}
.node-pct { font-weight: 700; line-height: 1; }
.node-name { line-height: 1.2; margin-top: 1px; }
/* Tooltip */
.tooltip {
position: absolute;
pointer-events: none;
padding: 0.5rem 0.75rem;
border-radius: 8px;
z-index: 10;
max-width: 250px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
color: #f5f5f5;
display: flex;
flex-direction: column;
gap: 2px;
}
:global(html:not(.dark)) .tooltip {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
.tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
.tooltip-name { font-size: 0.8125rem; font-weight: 600; }
.tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
/* Responsive */
@media (max-width: 900px) {
.layout {
padding: 70px 1rem 1rem;
height: auto;
min-height: 100vh;
overflow: visible;
}
.treemap-wrapper {
min-height: 350px;
height: 50vh;
flex: none;
}
}
@media (max-width: 640px) {
.layout { padding: 60px 0.75rem 1rem; }
.title { font-size: 1.375rem; }
.subtitle { font-size: 0.75rem; }
.meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.year-selector {
flex-wrap: wrap;
gap: 0.25rem;
}
.year-btn {
font-size: 0.625rem;
padding: 0.2rem 0.5rem;
}
.slider-row {
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.stats { font-size: 0.6875rem; }
.treemap-wrapper {
min-height: 300px;
height: 45vh;
}
.tooltip {
max-width: 200px;
font-size: 0.75rem;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 380px) {
.layout { padding: 55px 0.5rem 0.75rem; }
.title { font-size: 1.125rem; }
.treemap-wrapper {
min-height: 250px;
height: 40vh;
}
.year-btn {
font-size: 0.5625rem;
padding: 0.15rem 0.375rem;
}
}
</style>
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código rubro
// Jerarquía: Tipo (XX000) → Clase (XXX00) → Cuenta (XXXX0) → Subcuenta (XXXXX)
function getTipoCode(rubro) {
// Primeros 2 dígitos + 000 → ej: 11000, 12000
return rubro.substring(0, 2) + '000';
}
function getClaseCode(rubro) {
// Primeros 3 dígitos + 00 → ej: 11100, 11200
return rubro.substring(0, 3) + '00';
}
function getCuentaCode(rubro) {
// Primeros 4 dígitos + 0 → ej: 11110, 11120
return rubro.substring(0, 4) + '0';
}
function getNivelFromCode(rubro) {
// Determinar nivel basado en el patrón de ceros
if (rubro.endsWith('000')) return 'tipo'; // XX000
if (rubro.endsWith('00')) return 'clase'; // XXX00
if (rubro.endsWith('0')) return 'cuenta'; // XXXX0
return 'sub_cuenta'; // XXXXX
}
function getNextNivel(nivel) {
const niveles = { tipo: 'clase', clase: 'cuenta', cuenta: 'sub_cuenta' };
return niveles[nivel] || null;
}
const API_BASE = 'http://136.112.29.74/api/rubro';
export async function load({ params }) {
const { codigo } = params;
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('rubro', codigo);
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Rubro no encontrado');
const rubro = await res.json();
if (dbError || !data || data.length === 0) {
throw error(404, 'Rubro no encontrado');
}
const rubro = data[0];
const nivel = rubro.nivel || getNivelFromCode(codigo);
// Obtener jerarquía (padres e hijos)
const nivel = rubro.nivel;
let padres = [];
let hijos = [];
// Buscar padres según nivel
if (nivel === 'sub_cuenta') {
// Padres: tipo, clase, cuenta
const tipoCode = getTipoCode(codigo);
const claseCode = getClaseCode(codigo);
const cuentaCode = getCuentaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.in('rubro', [tipoCode, claseCode, cuentaCode])
.order('rubro');
if (padresData) padres = padresData;
} else if (nivel === 'cuenta') {
// Padres: tipo, clase
const tipoCode = getTipoCode(codigo);
const claseCode = getClaseCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.in('rubro', [tipoCode, claseCode])
.order('rubro');
if (padresData) padres = padresData;
} else if (nivel === 'clase') {
// Padre: tipo
const tipoCode = getTipoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('rubro', tipoCode);
if (padresData) padres = padresData;
}
// tipo no tiene padres
// Buscar hijos según nivel
const nextNivel = getNextNivel(nivel);
if (nextNivel) {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', nextNivel)
.like('rubro', `${codigo.replace(/0+$/, '')}%`)
.order('rubro');
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
const s = String(codigo).padStart(5, '0');
// Rubro codes are 5 digits: ABCDE
// tipo = AB000, clase = ABC00, cuenta = ABCD0, sub_cuenta = ABCDE
const tipoCode = s.substring(0, 2) + '000';
const claseCode = s.substring(0, 3) + '00';
const cuentaCode = s.substring(0, 4) + '0';
// Padres según nivel
const parentCodes = [];
if (nivel === 'sub_cuenta') parentCodes.push(cuentaCode, claseCode, tipoCode);
else if (nivel === 'cuenta') parentCodes.push(claseCode, tipoCode);
else if (nivel === 'clase') parentCodes.push(tipoCode);
padres = clasificador.filter(c => {
const rc = String(c.rubro).padStart(5, '0');
return parentCodes.includes(rc) && rc !== s;
});
if (hijosData) {
// Filtrar solo hijos directos (siguiente nivel)
hijos = hijosData.filter(h => {
// Hijos directos
if (nivel === 'tipo') {
// Hijos de tipo son clase: XXX00 donde XX = tipo
return h.rubro.startsWith(codigo.substring(0, 2)) && h.nivel === 'clase';
hijos = clasificador.filter(c => c.nivel === 'clase' && String(c.rubro).padStart(5, '0').substring(0, 2) === s.substring(0, 2));
} else if (nivel === 'clase') {
// Hijos de clase son cuenta: XXXX0 donde XXX = clase
return h.rubro.startsWith(codigo.substring(0, 3)) && h.nivel === 'cuenta';
hijos = clasificador.filter(c => (c.nivel === 'cuenta') && String(c.rubro).padStart(5, '0').substring(0, 3) === s.substring(0, 3));
} else if (nivel === 'cuenta') {
// Hijos de cuenta son sub_cuenta: XXXXX donde XXXX = cuenta
return h.rubro.startsWith(codigo.substring(0, 4)) && h.nivel === 'sub_cuenta';
}
return false;
});
hijos = clasificador.filter(c => (c.nivel === 'sub_cuenta') && String(c.rubro).padStart(5, '0').substring(0, 4) === s.substring(0, 4));
}
padres.sort((a, b) => String(a.rubro).localeCompare(String(b.rubro)));
hijos.sort((a, b) => String(a.rubro).localeCompare(String(b.rubro)));
}
} catch { /* optional */ }
return {
rubro,
padres,
hijos
};
return { rubro, padres, hijos };
}
......
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Spinner from '$lib/components/ui/Spinner.svelte';
import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
let { data } = $props();
let rubroData = $derived(data.rubro);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(false);
let hoveredYear = $state(null);
let lockedYear = $state(null);
let activeYear = $derived(lockedYear ?? hoveredYear);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita');
let vista = $state('agregado');
let linkCopied = $state(false);
const rubro = data.rubro;
const padres = data.padres;
const hijos = data.hijos;
// Inline search state
let searchInputRef = $state(null);
let searchVal = $state('');
let searchFocused = $state(false);
let isSearching = $state(false);
let localResults = $state([]);
let selectedIdx = $state(-1);
let debounceTimer;
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
async function doSearch(query) {
isSearching = true;
try {
const parsed = JSON.parse(descripcionesStr);
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
const params = new URLSearchParams({
q: query,
per_page: '20',
is_class: 'true',
class_: 'rubro'
});
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
localResults = (data.hits || []).map(hit => {
const meta = parseMetadatos(hit.document.metadatos);
const codigo = meta.rubro_sub_cuenta || meta.rubro_cuenta || meta.rubro_clase || meta.rubro_tipo || '';
const nombre = hit.document.texto;
const highlight = hit.highlights?.[0]?.snippet || nombre;
return { codigo, nombre, highlight };
});
selectedIdx = -1;
} catch {
localResults = [];
}
isSearching = false;
}
function handleSearchInput(e) {
searchVal = e.target.value;
clearTimeout(debounceTimer);
if (searchVal.length >= 2) {
debounceTimer = setTimeout(() => doSearch(searchVal), 200);
} else {
localResults = [];
}
}
function handleSearchKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (e.key === 'ArrowDown') {
selectedIdx = selectedIdx < localResults.length - 1 ? selectedIdx + 1 : 0;
} else {
selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : localResults.length - 1;
}
} else if (e.key === 'Enter' && localResults.length > 0) {
e.preventDefault();
const selected = localResults[selectedIdx >= 0 ? selectedIdx : 0];
if (selected) handleSearchSelect(selected);
} else if (e.key === 'Escape') {
searchVal = '';
localResults = [];
searchInputRef?.blur();
}
}
function handleSearchSelect(result) {
const url = vista === 'comparar'
? `/rubro/${result.codigo}?vista=comparar`
: `/rubro/${result.codigo}`;
goto(url);
searchVal = '';
localResults = [];
searchFocused = false;
}
function handleSearchBlur() {
setTimeout(() => { searchFocused = false; }, 150);
}
function handleClear() {
localResults = [];
searchVal = '';
}
// Parsear descripciones
function getMaxYear(str) {
if (!str) return 0;
const years = str.match(/\d{4}/g);
return years ? Math.max(...years.map(Number)) : 0;
}
let descripciones = $derived.by(() => {
const descs = rubroData.descripciones;
if (!descs) return [];
let arr;
if (typeof descs === 'string') {
try { arr = JSON.parse(descs); } catch { return []; }
} else {
arr = Array.isArray(descs) ? descs : [];
}
return [...arr].sort((a, b) => getMaxYear(b.gestion || b.rangos) - getMaxYear(a.gestion || a.rangos));
});
let variaciones = $derived(descripciones.map(d => ({ rango: d.gestion || d.rangos || '', texto: d.descripcion })));
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(entidades.length || data.nEntidades || 0);
// Datos
function initEstados(obj) {
const estadosRaw = obj?.estados || [];
const seen = new Set();
const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
const hist = estados.find(d => d.gestion === 0) || null;
const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
const topPorAño = {};
anuales.forEach(d => {
topPorAño[d.gestion] = [
{ nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
{ nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
{ nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
].filter(e => e.nombre);
});
return { hist, anuales, topPorAño };
}
let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(rubroData);
let datosAnuales = $state(initAnuales);
let datosHistorico = $state(initHist);
let topEntidadesPorAño = $state(initTop);
const POBLACION = 12000000;
let rubroCodigo = $derived(rubroData.rubro);
let rubroNivel = $derived(rubroData.nivel);
let gastoPerCapita = $derived(
datosAnuales.map(d => {
const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
return {
año: d.gestion,
monto,
perCapita: metrica === 'percapita' ? perCapita : monto
};
})
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
let ranking = $derived(datosHistorico?.ranking ?? 0);
let nPares = $derived(datosHistorico?.n_pares ?? 0);
let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
let currentData = $derived(
activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
);
let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
// KPI 1: Monto
let displayMonto = $derived.by(() => {
if (selectedEntity) {
return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
}
return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
});
let displayMontoLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `recaudados en ${currentAnual.gestion}` : `recaudados ${primerAño}-${ultimoAño}`;
}
return currentAnual ? `ingreso total del Estado en ${currentAnual.gestion}` : `ingreso total ${primerAño}-${ultimoAño}`;
});
// KPI 2: Per capita
let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
// KPI 3: Proporcion
let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
let displayPorcentajeLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `del total de este rubro en ${currentAnual.gestion}` : 'del total de este rubro';
}
return currentAnual ? `del ingreso en ${currentAnual.gestion}` : 'del ingreso total';
});
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
let nivelKey = $derived(rubroData.nivel?.toLowerCase());
let nivelPlural = $derived({ tipo: 'tipos', clase: 'clases', cuenta: 'cuentas', sub_cuenta: 'sub cuentas' }[nivelKey] || 'rubros');
let nivelLabel = $derived({ tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Sub cuenta' }[nivelKey] || rubroData.nivel);
let displayRankingLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `entre las que más reciben en ${currentAnual.gestion}` : 'entre las que más reciben';
}
return currentData ? `${nivelPlural} más recaudados en ${currentData.año}` : `${nivelPlural} más recaudados`;
});
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
{ nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
{ nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
$effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
$effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
if (!selectedEntity) return;
lockedYear = null;
loading = true;
try {
const res = await fetch(`/api/rubro-data?codigo=${rubroCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
const raw = await res.json();
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion >= 2016);
topEntidadesPorAño = {};
if (datosHistorico) {
twMonto.set(datosHistorico.monto || 0);
twPerCapita.set(datosHistorico.per_capita || 0);
twPorcentaje.set(datosHistorico.prop || 0);
}
}
} catch (err) {
console.error('[loadData] Error:', err);
}
loading = false;
}
async function loadEntidades() {
try {
// Usar el último año con datos reales (de estados), no de gestiones
const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
const año = añosConDatos[0] || 2025;
const entRes = await fetch(`/api/rubro-data?codigo=${rubroCodigo}&tipo=entidades-año&gestion=${año}`);
const entData = await entRes.json();
if (Array.isArray(entData)) {
entidades = entData
.filter(d => d.monto > 0 && d.entidad !== 0)
.map(d => ({ entidad: d.entidad, entidad_desc: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}` }))
.sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
}
} catch {
entidades = [];
}
}
// ══════════════════════════════════════════════════════════════
// COMPARACION
// ══════════════════════════════════════════════════════════════
const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
let hoveredYearComparar = $state(null);
let loadingComparacion = $state(false);
let isDefaultSelection = $state(false);
let alertDefaultDismissed = $state(false);
let alertRangosDismissed = $state(false);
let entidadCompararA = $state(null);
let entidadCompararB = $state(null);
let datosCompararA = $state({ anual: [], historico: null });
let datosCompararB = $state({ anual: [], historico: null });
let dropdownAOpen = $state(false);
let dropdownBOpen = $state(false);
let searchA = $state('');
let searchB = $state('');
let entityLimitA = $state(30);
let entityLimitB = $state(30);
let filteredEntidadesA = $derived.by(() => {
if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
const q = searchA.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitA);
});
let filteredEntidadesB = $derived.by(() => {
if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
const q = searchB.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitB);
});
async function loadDatosEntidad(entidad) {
try {
const res = await fetch(`/api/rubro-data?codigo=${rubroCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
const raw = await res.json();
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
}
} catch {}
return { anual: [], historico: null };
}
async function initComparacion() {
if (entidadCompararA || entidadCompararB) return;
if (selectedEntity) {
entidadCompararA = selectedEntity;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(selectedEntity);
loadingComparacion = false;
isDefaultSelection = false;
} else if (topEntidadesTotal.length >= 2) {
const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
if (top1 && top2) {
isDefaultSelection = true;
loadingComparacion = true;
entidadCompararA = top1;
datosCompararA = await loadDatosEntidad(top1);
entidadCompararB = top2;
datosCompararB = await loadDatosEntidad(top2);
loadingComparacion = false;
}
}
}
async function selectEntidadA(entidad) {
entidadCompararA = entidad;
dropdownAOpen = false;
searchA = '';
entityLimitA = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
async function selectEntidadB(entidad) {
entidadCompararB = entidad;
dropdownBOpen = false;
searchB = '';
entityLimitB = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararB = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
function handleEntityScroll(event, type) {
const el = event.target;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
}
}
let rangoAños = $state('todo');
let añosComunesTotales = $derived.by(() => {
const añosA = datosCompararA.anual.map(d => d.gestion);
const añosB = datosCompararB.anual.map(d => d.gestion);
if (añosA.length && añosB.length) {
const setB = new Set(añosB);
return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
}
if (añosA.length) return [...añosA].sort((a, b) => a - b);
if (añosB.length) return [...añosB].sort((a, b) => a - b);
return [];
});
let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
let rangoInfoA = $derived.by(() => {
if (!datosCompararA.anual.length) return null;
const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let rangoInfoB = $derived.by(() => {
if (!datosCompararB.anual.length) return null;
const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let tienenRangosDiferentes = $derived.by(() => {
if (!rangoInfoA || !rangoInfoB) return false;
return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
});
let añosComparacion = $derived.by(() => {
if (rangoAños === 'todo') return añosComunesTotales;
return añosComunesTotales.slice(-parseInt(rangoAños));
});
let datosGraficoA = $derived.by(() => {
const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
let datosGraficoB = $derived.by(() => {
const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
function valComparar(d) {
return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
}
let maxComparacion = $derived.by(() => {
const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
return Math.max(...all, 1) * 1.1;
});
let displayPeriodoComparar = $derived.by(() => {
if (hoveredYearComparar) return hoveredYearComparar;
if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
return 'Selecciona entidades';
});
let displayPropA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
return datosCompararA.historico?.prop || 0;
});
let displayPropB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
return datosCompararB.historico?.prop || 0;
});
let displayRankingA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
return datosCompararA.historico?.ranking || '-';
});
let displayRankingB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
return datosCompararB.historico?.ranking || '-';
});
let displayNEntidadesA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
return datosCompararA.historico?.n_entidades || '-';
});
let displayNEntidadesB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
return datosCompararB.historico?.n_entidades || '-';
});
let isDarkMode = $state(false);
$effect(() => {
if (typeof document !== 'undefined') {
const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
checkDark();
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
});
let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
function toNumber(v) { return typeof v === 'number' ? v : 0; }
$effect(() => {
const year = hoveredYearComparar;
const idx = year ? añosComparacion.indexOf(year) : -1;
twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
});
function updateCompararUrl() {
if (!mounted) return;
const url = new URL($page.url);
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
else url.searchParams.delete('entidadA');
if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
else url.searchParams.delete('entidadB');
goto(url.toString(), { replaceState: true, noScroll: true });
}
let previousVista = 'agregado';
let skipPopstate = false;
$effect(() => {
if (mounted && vista !== previousVista) {
previousVista = vista;
if (!skipPopstate) {
const url = new URL(window.location.href);
if (vista === 'comparar') {
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
} else {
url.searchParams.delete('vista');
url.searchParams.delete('entidadA');
url.searchParams.delete('entidadB');
}
history.pushState({ vista }, '', url.toString());
}
skipPopstate = false;
if (vista === 'comparar') initComparacion();
}
});
function getMaxYear(rangos) {
if (!rangos) return 0;
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
// Restaurar vista al navegar atrás/adelante
function handlePopstate(e) {
const params = new URL(window.location.href).searchParams;
const urlVista = params.get('vista') || 'agregado';
if (urlVista !== vista) {
skipPopstate = true;
vista = urlVista;
}
}
function getNivelLabel(nivel) {
const labels = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
return labels[nivel] || nivel;
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
return m?.toLocaleString('es-BO') || '0';
}
// Obtener la descripción correcta según el nivel
function getDescripcion(item) {
if (item.nivel === 'tipo') return item.desc_tipo;
if (item.nivel === 'clase') return item.desc_clase;
if (item.nivel === 'cuenta') return item.desc_cuenta;
if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta;
return item.desc_rubros;
// ══════════════════════════════════════════════════════════════
onMount(() => {
mounted = true;
loadEntidades().then(async () => {
const urlVista = $page.url.searchParams.get('vista');
const urlEntidad = $page.url.searchParams.get('entidad');
const urlEntidadA = $page.url.searchParams.get('entidadA');
const urlEntidadB = $page.url.searchParams.get('entidadB');
if (urlVista === 'comparar') {
vista = 'comparar';
if (urlEntidadA) {
const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
if (entA) {
entidadCompararA = entA;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entA);
}
}
if (urlEntidadB) {
const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
if (entB) {
entidadCompararB = entB;
datosCompararB = await loadDatosEntidad(entB);
}
}
loadingComparacion = false;
if (!urlEntidadA && !urlEntidadB) initComparacion();
} else if (urlEntidad && entidades.length > 0) {
const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
if (ent) selectedEntity = ent;
}
});
});
function getHijosLabel(nivel) {
const labels = { tipo: 'Clases', clase: 'Cuentas', cuenta: 'Subcuentas' };
return labels[nivel] || 'Hijos';
// Recargar cuando cambia el rubro
let prevCodigo = null;
$effect(() => {
const code = rubroCodigo;
if (prevCodigo !== null && code !== prevCodigo) {
entidadCompararA = null;
entidadCompararB = null;
datosCompararA = { anual: [], historico: null };
datosCompararB = { anual: [], historico: null };
const { hist, anuales, topPorAño } = initEstados(rubroData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
const prevEntity = selectedEntity;
loadEntidades().then(() => {
if (prevEntity) {
const match = entidades.find(e => e.entidad === prevEntity.entidad);
if (match) {
selectedEntity = match;
loadData();
} else {
selectedEntity = null;
}
}
});
}
prevCodigo = code;
});
const descripciones = parseDescripciones(rubro.descripciones);
// Recargar cuando cambia la entidad seleccionada
let prevEntity = null;
$effect(() => {
const ent = selectedEntity;
if (ent !== prevEntity) {
prevEntity = ent;
if (ent) {
loadData();
} else if (prevCodigo) {
const { hist, anuales, topPorAño } = initEstados(rubroData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
}
if (mounted) {
const url = new URL($page.url);
if (ent) {
url.searchParams.set('entidad', ent.entidad);
} else {
url.searchParams.delete('entidad');
}
goto(url.toString(), { replaceState: true, noScroll: true });
}
}
});
</script>
<svelte:window on:popstate={handlePopstate} />
<svelte:head>
<title>{getDescripcion(rubro)} | Presupuesto Público</title>
<title>{rubroData?.desc_rubro || `Rubro ${rubroCodigo}`} | Presupuesto Abierto</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="min-h-screen bg-white">
<header class="border-b bg-slate-50">
<div class="max-w-4xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span>/</span>
<a href="/clasificadores/rubros" class="hover:text-slate-700">Rubros</a>
{#each padres as padre}
<span>/</span>
<a href="/rubro/{padre.rubro}" class="hover:text-slate-700">{getDescripcion(padre)}</a>
<div class="page" class:mounted>
{#if vista === 'agregado'}
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
<!-- Mobile toggle -->
<button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
</button>
<div class="context-card">
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar (inline) -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<div class="inline-search-wrapper">
<div class="inline-search-input-wrap">
<input
bind:this={searchInputRef}
type="text"
value={searchVal}
oninput={handleSearchInput}
onkeydown={handleSearchKeydown}
onfocus={() => searchFocused = true}
onblur={handleSearchBlur}
placeholder="Buscar rubro de ingreso..."
class="inline-search-input"
/>
{#if searchVal}
<button class="inline-search-clear" onclick={handleClear}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
{#if searchFocused && localResults.length > 0}
<div class="inline-search-results">
{#each localResults as result, i}
<button
class="inline-search-item"
class:selected={i === selectedIdx}
onmousedown={() => handleSearchSelect(result)}
>
<span class="inline-search-code">{result.codigo}</span>
<span class="inline-search-name">{@html result.highlight}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
<!-- Navegacion -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegacion</h3>
<p class="context-hint">Rubros relacionados. Haz click para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/rubro/{padre.rubro}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_rubro}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.rubro}</span>
<span class="tree-name">{padre.desc_rubro}</span>
</a>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{rubroData.desc_rubro}">
<span class="tree-dot"></span>
<span class="tree-code">{rubroData.rubro}</span>
<span class="tree-name">{rubroData.desc_rubro}</span>
</div>
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/rubro/{hijo.rubro}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_rubro}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.rubro}</span>
<span class="tree-name">{hijo.desc_rubro}</span>
</a>
{/each}
<span>/</span>
<span class="text-slate-900">{getDescripcion(rubro)}</span>
</nav>
</div>
</header>
{/if}
</div>
</div>
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<div class="explore-header">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<span class="explore-count">7 &rarr;</span>
</div>
<div class="explore-scroll">
<div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">Quien recauda mas en {rubroData.desc_rubro?.toLowerCase()}?</span>
</div>
<a href="/rubro/{rubroData.rubro}/entidades" class="explore-card">
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que reciben en {rubroData.desc_rubro?.toLowerCase()}</span>
</a>
<a href="/clasificadores/rubros?modo=mapa" class="explore-card">
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuanto pesa {rubroData.desc_rubro?.toLowerCase()} respecto a otros ingresos</span>
</a>
<a href="/clasificadores/rubros?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
<span class="explore-title">Comparar rubros</span>
<span class="explore-desc">Todos los rubros entre dos momentos o entidades</span>
</a>
<a href="/clasificadores/rubros" class="explore-card">
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todos los rubros de ingreso y navegar entre ellos</span>
</a>
</div>
</div>
<main class="max-w-4xl mx-auto px-6 py-8">
<!-- Encabezado -->
<div class="mb-8">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(rubro.nivel)}</p>
<div class="flex items-baseline gap-4">
<span class="font-mono text-2xl text-slate-400">{rubro.rubro}</span>
<h1 class="text-2xl font-medium text-slate-900">{getDescripcion(rubro)}</h1>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el periodo</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Descripciones -->
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{#if descripciones.length > 1}
Descripciones ({descripciones.length} variaciones)
<!-- Definicion -->
<div class="context-section">
<h3 class="context-subtitle">Definicion</h3>
{#if variaciones.length <= 1}
<p class="context-hint">Definicion estable en todo el periodo</p>
{:else}
Descripción
<p class="context-hint">El significado de este ingreso ha cambiado {variaciones.length} veces en el periodo</p>
{/if}
</h2>
<button class="def-link" onclick={() => showDefinicionesModal = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
{#if variaciones.length <= 1}
Ver definicion
{:else}
Ver variaciones
{/if}
</button>
</div>
<div class="context-separator"></div>
<div class="space-y-4">
{#each descripciones as desc, i}
<div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200 bg-slate-50/50'} pl-4 py-3 rounded-r">
<p class="text-xs text-slate-500 mb-2">
{#if i === 0 && descripciones.length > 1}
<span class="text-blue-600 font-medium">Vigente</span>
<span class="mx-1">·</span>
<!-- Compartir -->
<div class="context-section">
<h3 class="context-subtitle">Compartir</h3>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
{desc.rangos}
</p>
<p class="text-slate-700 leading-relaxed">{desc.descripcion}</p>
</svg>
<span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
</button>
</div>
{/each}
</div>
</section>
</aside>
<!-- Jerarquía -->
{#if padres.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2>
<div class="space-y-2">
{#each padres as padre, i}
<div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem">
<span class="text-slate-300">└</span>
<a
href="/rubro/{padre.rubro}"
class="text-sm text-slate-600 hover:text-blue-600"
>
<span class="font-mono text-xs text-slate-400">{padre.rubro}</span>
{getDescripcion(padre)}
</a>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{rubroData.rubro}</span>
</div>
{/each}
<div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem">
<span class="text-blue-500">└</span>
<span class="text-sm font-medium text-blue-600">
<span class="font-mono text-xs">{rubro.rubro}</span>
{getDescripcion(rubro)}
</div>
<h2 class="main-title-name">{rubroData.desc_rubro}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
{#if selectedEntity}
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Todo el Estado
</button>
{/if}
</div>
<div class="chart-header">
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<span class="chart-period">. {displayPeriodo}</span>
</span>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else if gastoPerCapita.length === 0 && selectedEntity}
<div class="chart-no-data">
<p>Esta entidad no tiene registros en {rubroData.desc_rubro?.toLowerCase()} desde 2016</p>
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver a Todo el Estado
</button>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
</div>
</section>
{/if}
<!-- Hijos -->
{#if hijos.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{getHijosLabel(rubro.nivel)} ({hijos.length})
</h2>
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
<div class="border rounded-lg divide-y">
{#each hijos as hijo}
{@const hijoDescs = parseDescripciones(hijo.descripciones)}
<a
href="/rubro/{hijo.rubro}"
class="block px-4 py-3 hover:bg-slate-50 transition-colors"
>
<div class="flex items-baseline gap-3">
<span class="font-mono text-sm text-slate-400">{hijo.rubro}</span>
<span class="text-slate-900">{getDescripcion(hijo)}</span>
{#if hijo.n_variaciones > 1}
<span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<div class="ranking-header">
<h3 class="ranking-title">Concentracion del ingreso</h3>
<div class="ranking-summary">
<span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
<span class="ranking-summary-text">de {rubroData.desc_rubro?.toLowerCase()} lo reciben estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
</div>
</div>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<button class="ranking-card ranking-card-link" onclick={() => {
const match = entidades.find(e => e.entidad === ent.entidad);
selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
}}>
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
<svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
{/each}
</div>
</div>
{/if}
</main>
</div>
<!-- Modal de definiciones -->
{#if showDefinicionesModal}
<div class="modal-overlay" onclick={() => showDefinicionesModal = false}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Definiciones de "{rubroData.desc_rubro}"</h3>
<button class="modal-close" onclick={() => showDefinicionesModal = false}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{#if hijoDescs.length > 0}
<p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p>
<div class="modal-body">
{#each variaciones as v, i}
<div class="def-item" class:def-current={i === 0}>
<div class="def-header">
<span class="def-period">{v.rango}</span>
{#if i === 0}
<span class="def-badge">Vigente</span>
{/if}
</a>
</div>
<p class="def-text">{v.texto}</p>
</div>
{/each}
</div>
</section>
</div>
</div>
{/if}
<!-- Volver -->
<div class="pt-6 border-t">
<a
href="/clasificadores/rubros"
class="text-sm text-blue-600 hover:underline"
>
Volver al clasificador
</a>
{:else}
<!-- VISTA COMPARAR -->
<main class="main-comparar">
<div class="chart-card-comparar">
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{rubroData.rubro}</span>
</div>
</main>
</div>
<VistaToggle bind:vista />
</div>
<h2 class="main-title-name">{rubroData.desc_rubro}</h2>
<div class="comparador-selectors">
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorA}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
<span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if dropdownAOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntidadesA as entity}
<button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<span class="vs">vs</span>
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorB}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
<span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if dropdownBOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntidadesB as entity}
<button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
<div class="comparador-hint">
<span>Mostrando las dos entidades con mayor ingreso. Puedes cambiar la seleccion arriba.</span>
<button class="alert-close" onclick={() => alertDefaultDismissed = true}>&#x2715;</button>
</div>
{/if}
{#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
<div class="comparador-warning">
<span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} anos en comun.</span>
<button class="alert-close" onclick={() => alertRangosDismissed = true}>&#x2715;</button>
</div>
{/if}
{#if loadingComparacion}
<div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if !entidadCompararA && !entidadCompararB}
<div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
{:else if añosComparacion.length === 0}
<div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
{:else}
<div class="chart-header">
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<span class="chart-period">. {displayPeriodoComparar}</span>
</span>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
<div class="chart-comparar-wrapper">
<div class="chart-comparar-inner">
<span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
<span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
<span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
<div class="grid-line-comparar" style="bottom: 100%"></div>
<div class="grid-line-comparar" style="bottom: 50%"></div>
<div class="grid-line-comparar" style="bottom: 0%"></div>
<div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
{#each añosComparacion as año, i}
<div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
<div class="bars-pair">
<div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
<div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
</div>
</div>
{/each}
</div>
</div>
<div class="x-axis-comparar">
{#each añosComparacion as año, i}
{@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
<span class="x-label-comparar" class:visible={show}>{año}</span>
{/each}
</div>
</div>
<div class="chart-legend">
{#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
{#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
</div>
{/if}
</div>
{#if soloUnaEntidad}
<div class="comparador-hint comparador-hint-select">
<span>Selecciona la segunda entidad para ver la comparacion completa.</span>
</div>
{/if}
{#if entidadCompararA && entidadCompararB}
<div class="kpis-card-comparar">
<div class="tabla-header">
<span class="tabla-label"></span>
<span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
<span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Ingreso</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Per capita</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Peso</span>
<span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Ranking</span>
<span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
<span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
</div>
</div>
{/if}
</main>
{/if}
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
height: 100dvh;
width: 100%;
max-width: 100%;
overflow: hidden;
background: var(--theme-body);
padding-top: 60px;
box-sizing: border-box;
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 1rem 2rem 2rem;
height: 100%;
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
INLINE SEARCH
═══════════════════════════════════════════════════════════════ */
.inline-search-wrapper {
position: relative;
}
.inline-search-input-wrap {
position: relative;
}
.inline-search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 8px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
box-sizing: border-box;
}
.inline-search-input:focus { border-color: var(--theme-accent); }
.inline-search-clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--theme-texto);
opacity: 0.5;
cursor: pointer;
padding: 0;
line-height: 1;
}
.inline-search-clear:hover { opacity: 1; }
.inline-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
max-height: 240px;
overflow-y: auto;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
}
.inline-search-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.inline-search-item:hover,
.inline-search-item.selected { background: rgba(201, 167, 81, 0.1); }
.inline-search-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
min-width: 2.5rem;
flex-shrink: 0;
}
.inline-search-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.explore-count {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
opacity: 0.7;
animation: nudgeRight 1.5s ease-in-out infinite;
}
@keyframes nudgeRight {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-scroll {
-webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
mask-image: linear-gradient(to right, black 85%, transparent 100%);
}
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-titulo);
}
/* ═══════════════════════════════════════════════════════════════
DEFINICION
═══════════════════════════════════════════════════════════════ */
.def-link {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
.chart-no-data {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
font-size: 0.8125rem;
text-align: center;
}
.chart-no-data p {
margin: 0;
}
.volver-agregado {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-accent);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.volver-agregado:hover { opacity: 0.7; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.copy-link-btn svg { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.15;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.metrica-toggle {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
padding: 0.125rem 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.metrica-toggle:hover {
background: rgba(201, 167, 81, 0.12);
border-color: rgba(201, 167, 81, 0.3);
color: var(--theme-accent);
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
text-align: center;
}
.kpi-value {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ranking-card {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: transparent;
text-align: left;
}
.ranking-card-link {
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.ranking-card-link:hover {
background: rgba(201, 167, 81, 0.05);
}
.ranking-card-link:hover .ranking-name {
color: var(--theme-accent);
border-bottom-style: solid;
}
.ranking-arrow {
opacity: 0;
color: var(--theme-accent);
transition: opacity 0.15s, transform 0.15s;
flex-shrink: 0;
}
.ranking-card-link:hover .ranking-arrow {
opacity: 1;
transform: translateX(2px);
}
.ranking-header {
margin-bottom: 0.5rem;
}
.ranking-summary {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin-top: 0.125rem;
}
.ranking-summary-pct {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
font-weight: 700;
color: var(--theme-titulo);
}
.ranking-summary-text {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
font-size: 0.8125rem;
color: var(--theme-titulo);
border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
opacity: 0.7;
}
/* ═══════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--theme-surface);
border-radius: 16px;
max-width: 560px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--theme-borde);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.modal-close {
color: var(--theme-texto);
background: transparent;
border: none;
padding: 0.25rem;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.modal-close:hover {
background: var(--theme-borde);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.def-item {
padding: 1rem;
background: var(--theme-body);
border-radius: 10px;
border-left: 3px solid var(--theme-borde);
}
.def-current {
border-left-color: #6B9FD4;
}
.def-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.def-period {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
color: #6B9FD4;
}
.def-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
background: #4A9E6B;
padding: 0.125rem 0.5rem;
border-radius: 4px;
}
.def-text {
font-size: 0.875rem;
color: var(--theme-texto);
line-height: 1.6;
margin: 0;
}
:global(html.dark) .def-current {
border-left-color: var(--theme-accent);
}
:global(html.dark) .def-period {
color: var(--theme-accent);
}
/* ═══════════════════════════════════════════════════════════════
MOBILE SIDEBAR
═══════════════════════════════════════════════════════════════ */
.mobile-sidebar-toggle {
display: none;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
gap: 0.5rem;
height: auto;
overflow: visible;
padding: 1.5rem 1rem 2rem;
align-content: center;
min-height: calc(100vh - 4rem);
}
.dashboard-main {
overflow: visible;
}
.chart-protagonista {
flex: none;
}
.ranking-section {
flex: none;
}
.mobile-sidebar-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
color: var(--theme-titulo);
font-family: 'Qanelas', var(--font-sans);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.25rem;
width: 100%;
}
.mobile-sidebar-toggle:hover {
background: var(--theme-surface-hover);
}
.mobile-sidebar-toggle svg {
color: var(--theme-texto);
opacity: 0.6;
}
.dashboard-context .context-card {
display: none;
}
.dashboard-context.mobile-open .context-card {
display: flex;
animation: slideDown 0.25s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.context-card {
height: auto;
overflow-y: visible;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.dashboard-layout {
padding: 0.5rem;
}
.chart-protagonista,
.ranking-section,
.context-card {
padding: 0.5rem;
border-radius: 8px;
}
.main-title-name {
font-size: 1.375rem;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.kpi-value {
font-size: 0.8125rem;
}
.kpi-label {
font-size: 0.5625rem;
}
.explore-scroll {
gap: 6px;
}
.explore-card {
width: 100px;
height: 80px;
}
.explore-title {
font-size: 0.625rem;
}
.explore-desc {
font-size: 0.5rem;
}
.ranking-title {
font-size: 0.875rem;
}
.ranking-name {
font-size: 0.6875rem;
}
.ranking-pct {
font-size: 0.75rem;
}
.modal-content {
margin: 0.5rem;
max-height: 90vh;
}
.tree-name {
max-width: 150px;
}
}
/* ═══════════════════════════════════════════════════════════════
DARK MODE
═══════════════════════════════════════════════════════════════ */
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.5);
}
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
/* ═══════════════════════════════════════════════════════════════
COMPARAR VIEW
═══════════════════════════════════════════════════════════════ */
.main-comparar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 2rem 2rem;
width: 100%;
max-width: 1400px;
margin: 0 auto;
height: 100%;
overflow: hidden;
}
.chart-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.comparador-selectors {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
.selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
.relative { position: relative; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selector-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 8px;
font-size: 0.8125rem;
color: var(--theme-titulo);
cursor: pointer;
transition: border-color 0.2s;
}
.selector-btn:hover { border-color: var(--theme-accent); }
.comparador-btn { width: 100%; justify-content: space-between; }
.comparador-dropdown-container { position: relative; }
.dropdown-panel.comparador-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
min-width: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
}
.search-input:focus { border-color: var(--theme-accent); }
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover { background: var(--theme-surface-hover); }
.dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
.dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
.dropdown-list { max-height: 240px; overflow-y: auto; }
.comparador-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed var(--theme-borde);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
.comparador-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(107, 159, 212, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #6B9FD4;
}
.comparador-hint span { flex: 1; }
.comparador-hint-select { margin-top: 0.75rem; }
.comparador-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(230, 176, 75, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #C9A751;
}
.comparador-warning span { flex: 1; }
.alert-close {
flex-shrink: 0;
padding: 0.25rem;
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
border-radius: 4px;
}
.alert-close:hover { opacity: 1; }
.chart-comparar-wrapper {
position: relative;
width: 100%;
flex: 1;
min-height: 180px;
}
.chart-comparar-inner {
position: absolute;
top: 10px;
bottom: 30px;
left: 50px;
right: 10px;
}
.y-label-comparar {
position: absolute;
left: -50px;
width: 45px;
text-align: right;
transform: translateY(50%);
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
white-space: nowrap;
}
.grid-line-comparar {
position: absolute;
left: 0;
right: 0;
border-top: 0.5px dotted var(--theme-texto);
opacity: 0.15;
}
.bars-comparar {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: 3px;
}
.bar-group-comparar {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: pointer;
}
.bars-pair {
display: flex;
align-items: flex-end;
gap: 2px;
height: 100%;
width: 100%;
justify-content: center;
}
.bar-comparar {
flex: 1;
max-width: 24px;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: opacity 0.15s;
}
.bars-comparar:hover .bar-comparar { opacity: 0.35; }
.bar-group-comparar.active .bar-comparar { opacity: 1; }
.x-axis-comparar {
position: absolute;
bottom: 0;
left: 50px;
right: 10px;
height: 30px;
display: flex;
align-items: center;
}
.x-label-comparar {
flex: 1;
text-align: center;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s;
}
.x-label-comparar.visible { opacity: 1; }
.chart-legend {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--theme-texto);
}
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
.kpis-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem 1.25rem;
}
.tabla-header {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--theme-borde);
margin-bottom: 0.25rem;
}
.tabla-header .tabla-val {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
.tabla-entidad {
font-size: 0.6875rem;
line-height: 1.3;
word-break: break-word;
}
.tabla-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding: 0.1875rem 0;
}
.tabla-label {
font-size: 0.6875rem;
font-weight: 500;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tabla-val {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
text-align: center;
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.main-comparar { padding: 0.75rem; }
.chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
.comparador-selectors { flex-direction: column; gap: 0.75rem; }
.vs { display: none; }
}
</style>
......
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import * as d3 from 'd3';
let codigo = $derived($page.params.codigo);
// Estado
let loading = $state(true);
let mounted = $state(false);
let rubroInfo = $state(null);
let entidadesData = $state([]);
let selectedYear = $state(null);
let linkCopied = $state(false);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
let entidadClasificador = $state({});
let subareas = $state([]);
let sliderMin = $state(0);
let sliderMax = $state(100);
// Filtrar entidades por rango de % acumulado
let filteredEntidades = $derived.by(() => {
if (sliderMin === 0 && sliderMax === 100) return entidadesData;
let acum = 0;
return entidadesData.filter(d => {
const pctBefore = (acum / totalDevengado) * 100;
acum += d.monto;
const pctAfter = (acum / totalDevengado) * 100;
return pctAfter > sliderMin && pctBefore < sliderMax;
});
});
let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
let filteredCount = $derived(filteredEntidades.length);
// Treemap
let treemapContainer = $state(null);
let treemapWidth = $state(0);
let treemapHeight = $state(0);
let treemapNodes = $state([]);
let treemapGroups = $state([]);
// Anos disponibles desde gestiones del rubro
let availableYears = $derived.by(() => {
if (!rubroInfo?.gestiones) return [];
// Filtrar años con datos reales: usar los estados del rubro
if (rubroInfo.estados && rubroInfo.estados.length > 0) {
return rubroInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
}
return rubroInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
});
// Formatters
function formatMonto(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
return v?.toLocaleString('es-BO') || '0';
}
function formatCompact(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
return v?.toFixed(0) || '0';
}
// Colores por subarea
let isDark = $state(false);
const SUBAREA_COLORS = [
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
];
function getSubareaColor(subarea) {
const idx = subareas.indexOf(subarea);
return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
}
function getTextColorForBg(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.95)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
function getTextColorSecondary(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.88)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
}
// Cargar clasificador de entidades
async function loadClasificador() {
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (Array.isArray(data)) {
const map = {};
data.forEach(d => { map[d.entidad] = d; });
entidadClasificador = map;
}
} catch {}
}
// Cargar info del rubro desde API
async function loadRubro() {
try {
const res = await fetch(`/api/rubro-data?codigo=${codigo}&tipo=clasificador`);
const data = await res.json();
if (data && !data.error) rubroInfo = data;
} catch {}
}
// Cargar entidades desde API
async function loadEntidades() {
if (!selectedYear || !codigo) return;
loading = true;
try {
const res = await fetch(`/api/rubro-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
const raw = await res.json();
if (Array.isArray(raw)) {
// Deduplicar por entidad
const map = new Map();
raw.forEach(d => {
if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
map.set(d.entidad, d);
}
});
entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
// Rubro entidades endpoint returns entidad_desc directly
entidadesData = entidadesData.map(d => ({
...d,
desc_entidad: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}`,
desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
}));
// Extraer subareas unicas ordenadas por monto total
const subareaMontos = {};
entidadesData.forEach(d => {
subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
});
subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
buildTreemap();
}
} catch (err) {
console.error('[Entidades] Error:', err);
}
loading = false;
}
function buildTreemap() {
if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
treemapNodes = [];
treemapGroups = [];
return;
}
// Agrupar por subarea
const groups = {};
filteredEntidades.forEach(d => {
const sub = d.desc_subarea || 'Otros';
if (!groups[sub]) groups[sub] = [];
groups[sub].push({
id: d.entidad,
name: d.desc_entidad || `Entidad ${d.entidad}`,
value: d.monto,
ranking: d.ranking,
per_capita: d.per_capita,
prop: d.prop,
subarea: sub
});
});
const hierarchy = d3.hierarchy({
name: 'root',
children: Object.entries(groups).map(([subarea, children]) => ({
name: subarea,
children
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(3)
.paddingInner(1)
.paddingTop(18)
.round(true)(hierarchy);
treemapNodes = hierarchy.leaves() || [];
treemapGroups = hierarchy.children || [];
}
function changeYear(year) {
selectedYear = year;
goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
loadEntidades();
}
$effect(() => {
if (treemapContainer) {
const measure = () => {
const w = treemapContainer.clientWidth;
const h = treemapContainer.clientHeight;
if (w > 0 && h > 0) {
treemapWidth = w;
treemapHeight = h;
buildTreemap();
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(treemapContainer);
return () => observer.disconnect();
}
});
onMount(async () => {
mounted = true;
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
await Promise.all([loadRubro(), loadClasificador()]);
const yearParam = $page.url.searchParams.get('gestion');
if (yearParam) {
const y = parseInt(yearParam);
selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
} else {
selectedYear = availableYears[0] || 2025;
}
if (treemapContainer) {
treemapWidth = treemapContainer.clientWidth;
treemapHeight = treemapContainer.clientHeight;
}
await loadEntidades();
});
</script>
<svelte:head>
<title>{rubroInfo?.desc_rubro || `Rubro ${codigo}`} - Ranking de Entidades</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page" class:mounted>
<div class="layout">
<!-- Header -->
<header class="header">
<div class="header-top">
<div class="pills">
<span class="pill pill-nivel">{rubroInfo?.nivel || 'rubro'}</span>
<span class="pill pill-codigo">{codigo}</span>
</div>
<div class="header-actions">
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
</div>
<h1 class="title">{rubroInfo?.desc_rubro || 'Cargando...'}</h1>
<p class="subtitle">Que entidades recaudan mas en este rubro de ingreso?</p>
<!-- Ano + Stats -->
<div class="meta-row">
<div class="year-selector">
{#each availableYears as year}
<button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
{/each}
</div>
{#if !loading && entidadesData.length > 0}
<div class="stats">
<span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
</div>
{/if}
</div>
<!-- Slider de rango -->
{#if !loading && entidadesData.length > 0}
<div class="slider-row">
<span class="slider-label">Rango: {sliderMin}% - {sliderMax}%</span>
<span class="slider-info">{filteredCount} entidades . {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del ingreso</span>
</div>
<div class="range-slider">
<input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
<input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
</div>
{/if}
</header>
<!-- Treemap -->
<div class="treemap-wrapper" bind:this={treemapContainer}>
{#if loading}
<div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if treemapNodes.length > 0}
<svg width={treemapWidth} height={treemapHeight}>
<!-- Group labels (subareas) -->
{#each treemapGroups as group}
{@const gw = group.x1 - group.x0}
{@const color = getSubareaColor(group.data.name)}
{@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
{#if gw > 50}
<rect
x={group.x0 + 3}
y={group.y0 + 2}
width={Math.min(label.length * 6.5 + 10, gw - 6)}
height="14"
rx="3"
fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
/>
<text
x={group.x0 + 8}
y={group.y0 + 13}
fill={isDark ? '#F5F0E8' : '#1a1a1a'}
font-size="9"
font-family="'Qanelas', var(--font-sans)"
font-weight="600"
>
{label}
</text>
{/if}
{/each}
<!-- Entity nodes -->
{#each treemapNodes as node, i}
{@const w = node.x1 - node.x0}
{@const h = node.y1 - node.y0}
{@const pct = (node.value / totalDevengado) * 100}
{@const isHovered = hoveredNode === node}
{@const area = Math.sqrt(w * h)}
{@const pctSize = Math.min(20, Math.max(9, area / 8))}
{@const nameSize = Math.min(12, Math.max(7, area / 12))}
{@const color = getSubareaColor(node.data.subarea)}
<g
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
onclick={() => goto(`/rubro/${codigo}?entidad=${node.data.id}`)}
>
<rect
width={w} height={h}
fill={color}
opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
stroke-width={isHovered ? 2 : 0.5}
rx="2"
/>
{#if w > 35 && h > 22}
{@const textColor = getTextColorForBg(color)}
{@const textColorSec = getTextColorSecondary(color)}
<foreignObject x="4" y="3" width={w - 8} height={h - 6}>
<div class="node-text">
<span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
{#if h > 40 && w > 55}
<span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/each}
</svg>
{#if hoveredNode}
{@const pct = (hoveredNode.value / totalDevengado) * 100}
<div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
<span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
<span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
<span class="tooltip-name">{hoveredNode.data.name}</span>
<span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs . {pct.toFixed(1)}%</span>
</div>
{/if}
{:else}
<div class="loading">No hay datos para {selectedYear}</div>
{/if}
</div>
</div>
</div>
<style>
.page {
min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 1400px;
margin: 0 auto;
padding: 80px 2rem 2rem;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header { flex-shrink: 0; margin-bottom: 1rem; }
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.pills { display: flex; gap: 0.375rem; }
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
.pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.25rem;
line-height: 1.15;
}
.subtitle {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
.year-selector::-webkit-scrollbar { display: none; }
.year-btn {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
}
.year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
.year-btn.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: white;
font-weight: 600;
}
.stats {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.stats strong { color: var(--theme-titulo); font-weight: 600; }
/* Slider */
.slider-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.slider-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-titulo);
font-weight: 600;
}
.slider-info {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
}
.range-slider {
position: relative;
height: 20px;
margin-bottom: 0.5rem;
}
.range-slider input[type="range"] {
position: absolute;
width: 100%;
height: 4px;
top: 8px;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range-slider input[type="range"]:first-child {
background: var(--theme-borde);
border-radius: 2px;
}
.range-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.range-slider input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Treemap */
.treemap-wrapper {
flex: 1;
min-height: 200px;
position: relative;
background: var(--theme-surface);
border-radius: 12px;
overflow: hidden;
}
.treemap-wrapper svg { display: block; width: 100%; height: 100%; }
.loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
font-size: 0.875rem;
}
.node-text {
display: flex;
flex-direction: column;
overflow: hidden;
}
.node-pct { font-weight: 700; line-height: 1; }
.node-name { line-height: 1.2; margin-top: 1px; }
/* Tooltip */
.tooltip {
position: absolute;
pointer-events: none;
padding: 0.5rem 0.75rem;
border-radius: 8px;
z-index: 10;
max-width: 250px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
color: #f5f5f5;
display: flex;
flex-direction: column;
gap: 2px;
}
:global(html:not(.dark)) .tooltip {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
.tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
.tooltip-name { font-size: 0.8125rem; font-weight: 600; }
.tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
/* Responsive */
@media (max-width: 900px) {
.layout {
padding: 70px 1rem 1rem;
height: auto;
min-height: 100vh;
overflow: visible;
}
.treemap-wrapper {
min-height: 350px;
height: 50vh;
flex: none;
}
}
@media (max-width: 640px) {
.layout { padding: 60px 0.75rem 1rem; }
.title { font-size: 1.375rem; }
.subtitle { font-size: 0.75rem; }
.meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.year-selector {
flex-wrap: wrap;
gap: 0.25rem;
}
.year-btn {
font-size: 0.625rem;
padding: 0.2rem 0.5rem;
}
.slider-row {
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.stats { font-size: 0.6875rem; }
.treemap-wrapper {
min-height: 300px;
height: 45vh;
}
.tooltip {
max-width: 200px;
font-size: 0.75rem;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 380px) {
.layout { padding: 55px 0.5rem 0.75rem; }
.title { font-size: 1.125rem; }
.treemap-wrapper {
min-height: 250px;
height: 40vh;
}
.year-btn {
font-size: 0.5625rem;
padding: 0.15rem 0.375rem;
}
}
</style>
......@@ -44,7 +44,7 @@
const CLAS_SEARCH_MAP = {
objeto: { class_: 'objeto', codigoFn: (meta) => meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '' },
finfun: { class_: 'finfun', codigoFn: (meta) => {
finfun: { class_: 'finalidad', codigoFn: (meta) => {
const fin = String(meta.finfun_finalidad || '');
if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
if (meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}`;
......@@ -123,13 +123,14 @@
resumen = clasUbigeoCache[key];
return;
}
const params = new URLSearchParams({ codigo });
if (gestion) params.set('gestion', gestion);
try {
const res = await fetch(`${apiEndpoint}?${params}`);
const data = await res.json();
const mapped = (data || [])
.filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo))
.filter(d => d.ubigeo !== '0.0.0' && !d.ubigeo.endsWith('.0') && !/multimunicipal/i.test(d.desc_ubigeo) && !/multiprovincial/i.test(d.desc_ubigeo) && !/desconocido/i.test(d.desc_ubigeo))
.map(d => ({
codigo: d.ubigeo,
desc: d.desc_ubigeo,
......@@ -403,6 +404,18 @@
const data = await res.json();
if (data?.desc_objeto) return data.desc_objeto;
}
} else if (clas === 'finfun') {
const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=clasificador`);
if (res.ok) {
const data = await res.json();
if (data?.desc_finfun) return data.desc_finfun;
}
} else if (clas === 'acteco') {
const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=clasificador`);
if (res.ok) {
const data = await res.json();
if (data?.desc_acteco) return data.desc_acteco;
}
}
// Fallback: buscar en Typesense
const cfg = CLAS_SEARCH_MAP[clas];
......@@ -482,6 +495,7 @@
<title>Gasto por geografía | Presupuesto Público</title>
</svelte:head>
{#if mapaData}
<div class="dashboard">
<div class="mapa-fullscreen">
<div class="mapa-top-controls">
......@@ -496,6 +510,11 @@
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Volver a {clasSeleccionado.nombre}
</a>
{:else if clasSeleccionado && clasificadorSeleccionado === 'acteco'}
<a href="/acteco/{clasSeleccionado.codigo}" class="back-link-mapa">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Volver a {clasSeleccionado.nombre}
</a>
{:else}
<span class="mapa-location-label">Ubicación geográfica</span>
{/if}
......@@ -698,7 +717,7 @@
<span class="tooltip-rank">#{pos}/{allBarras.length}</span>
{/if}
</div>
<span class="mapa-muni-count">{barrasRankeadas.length} municipios</span>
<span class="mapa-muni-count">{barrasRankeadas.length} municipios/TIOCs</span>
</div>
</div>
......@@ -721,7 +740,7 @@
<!-- Drawer lateral de ranking -->
<div class="ranking-drawer" class:open={drawerOpen}>
<div class="drawer-header">
<span class="drawer-titulo">{barrasRankeadas.length} municipios</span>
<span class="drawer-titulo">{barrasRankeadas.length} municipios/TIOCs</span>
<button class="drawer-close" onclick={() => { drawerOpen = false; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
......@@ -769,8 +788,33 @@
</div>
</div>
</div>
{:else}
<div class="mapa-loading">
<div class="mapa-loading-inner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="animation: spin 1.5s linear infinite; opacity: 0.4;">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span style="font-family: var(--font-sans); font-size: 13px; color: var(--theme-texto); opacity: 0.5;">Cargando mapa...</span>
</div>
</div>
{/if}
<style>
.mapa-loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--theme-body);
}
.mapa-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.dashboard {
min-height: 100vh;
background: var(--theme-body);
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params, fetch }) {
const codigo = params.ubigeo;
const municipioUbigeo = params.ubigeo;
// Cargar resumen y población en paralelo
const [resumenRes, pobRes] = await Promise.all([
supabase
.schema('ppto')
.from('entidad_resumen')
.select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
.eq('codigo', codigo),
// Primero obtener el clasificador para mapear municipio_ubigeo → ubigeo con puntos
const clasRes = await fetch('/api/ubigeo-data?codigo=0&tipo=clasificador');
if (!clasRes.ok) throw error(500, 'Error cargando clasificador');
const clasificador = await clasRes.json();
const match = clasificador.find(c => String(c.municipio_ubigeo) === municipioUbigeo);
if (!match) throw error(404, 'Ubicación no encontrada');
const ubigeoCode = match.ubigeo; // formato "2.7.7"
// Cargar detalle del ubigeo + población en paralelo
const [ubigeoRes, pobRes] = await Promise.all([
fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=detalle`).then(r => r.ok ? r.json() : null),
fetch('/poblacion.csv').then(r => r.text())
]);
if (resumenRes.error || !resumenRes.data?.length) {
throw error(404, 'Ubicación no encontrada');
}
if (!ubigeoRes) throw error(404, 'Ubicación no encontrada');
// Parsear población para este código (codigo_ine)
// Población
const poblacionMap = {};
pobRes.split('\n').slice(1).forEach(line => {
const [cod, , gestion, pob] = line.split(',');
if (cod === codigo) {
if (cod === municipioUbigeo) {
poblacionMap[parseInt(gestion)] = parseInt(pob);
}
});
// Determinar última gestión
const gestiones = [...new Set(resumenRes.data.map(d => d.gestion))].sort((a, b) => b - a);
const ultimaGestion = gestiones[0] || 2025;
// gastos_ingresos ��� resumenData
const gastosIngresos = ubigeoRes.gastos_ingresos || [];
const resumenData = gastosIngresos.map(d => ({
tipo: d.tipo,
gestion: d.gestion,
devengado: d.devengado,
ranking: d.ranking,
codigo: municipioUbigeo,
desc: ubigeoRes.desc_ubigeo
}));
// Cargar distribuciones de la última gestión
const distRes = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigo)
.eq('gestion', ultimaGestion);
// Última gestión
const gestiones = [...new Set(gastosIngresos.map(d => d.gestion))].filter(g => g <= 2025).sort((a, b) => b - a);
const ultimaGestion = gestiones[0] || 2025;
const firstRow = resumenRes.data[0];
// Distribuciones de la última gestión
const distEndpoints = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' }
];
const distResults = await Promise.all(
distEndpoints.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=${api}`);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter(d => d.gestion === ultimaGestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
return {
nombre: firstRow.desc || `Ubicación ${codigo}`,
nombrePadre: firstRow.desc_padre || null,
codigo,
resumenData: resumenRes.data || [],
distribucionesData: distRes.data || [],
nombre: ubigeoRes.desc_ubigeo || `Ubicación ${municipioUbigeo}`,
nombrePadre: ubigeoRes.desc_departamento || null,
codigo: municipioUbigeo,
ubigeoCode,
resumenData,
distribucionesData: distResults.flat(),
gestionInicial: ultimaGestion,
poblacionMap
};
......
......@@ -3,7 +3,6 @@
import { page } from '$app/stores';
import { get } from 'svelte/store';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
import { mapaCache } from '$lib/stores/mapaCache';
let { data } = $props();
......@@ -107,15 +106,41 @@
return;
}
cargandoDist = true;
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigoSeleccionado)
.eq('gestion', gestion);
const result = rows || [];
const ubigeoCode = data.ubigeoCode;
const dims = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' }
];
try {
const results = await Promise.all(
dims.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=${api}`);
if (!res.ok) return [];
const rows = await res.json();
if (!Array.isArray(rows)) return [];
return rows
.filter(d => d.gestion === gestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
const result = results.flat();
distCache[gestion] = result;
distribucionesData = result;
} catch {
distribucionesData = [];
}
cargandoDist = false;
}
......
This diff could not be displayed because it is too large.