Rafael Lopez

vista diaria

......@@ -8,9 +8,11 @@
"name": "ppto",
"version": "0.0.1",
"dependencies": {
"@observablehq/plot": "^0.6.17",
"@supabase/supabase-js": "^2.98.0",
"d3": "^7.9.0",
"fuse.js": "^7.1.0",
"hyparquet": "^1.25.1",
"marked": "^17.0.4"
},
"devDependencies": {
......@@ -515,6 +517,20 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@observablehq/plot": {
"version": "0.6.17",
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
"license": "ISC",
"dependencies": {
"d3": "^7.9.0",
"interval-tree-1d": "^1.0.0",
"isoformat": "^0.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
......@@ -1410,6 +1426,12 @@
"node": ">= 0.4"
}
},
"node_modules/binary-search-bounds": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz",
"integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
......@@ -1998,6 +2020,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/hyparquet": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.25.1.tgz",
"integrity": "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==",
"license": "MIT"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
......@@ -2028,6 +2056,15 @@
"node": ">=12"
}
},
"node_modules/interval-tree-1d": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz",
"integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==",
"license": "MIT",
"dependencies": {
"binary-search-bounds": "^2.0.0"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
......@@ -2038,6 +2075,12 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/isoformat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz",
"integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
......
......@@ -19,9 +19,11 @@
"vite": "^7.3.1"
},
"dependencies": {
"@observablehq/plot": "^0.6.17",
"@supabase/supabase-js": "^2.98.0",
"d3": "^7.9.0",
"fuse.js": "^7.1.0",
"hyparquet": "^1.25.1",
"marked": "^17.0.4"
}
}
......
......@@ -32,8 +32,14 @@
});
</script>
<!-- MÓVIL: Búsqueda + Theme + hamburguesa arriba a la derecha -->
<!-- MÓVIL: Home + Búsqueda + Theme + hamburguesa arriba a la derecha -->
<div class="navbar-mobile">
<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"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</a>
<button
onclick={onOpenSearch}
class="nav-btn-icon-mobile"
......@@ -70,10 +76,25 @@
</button>
</div>
<!-- DESKTOP: Barra completa con Search + Theme + Menu -->
<!-- DESKTOP: Barra completa con Home + Search + Theme + Menu -->
<div class="navbar-desktop">
<div class="navbar-desktop-inner">
<!-- BOTÓN HOME -->
<div class="relative group/home">
<a href="/" class="nav-btn-icon" 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"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</a>
<!-- Tooltip -->
<div class="absolute right-0 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover/home:opacity-100 group-hover/home:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Inicio
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- BOTÓN BÚSQUEDA GLOBAL -->
<div class="relative group/search">
<button
......@@ -152,6 +173,13 @@
z-index: 110;
align-items: center;
gap: 0.25rem;
padding: 6px;
background: rgba(40, 40, 38, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
.navbar-desktop {
......@@ -160,6 +188,20 @@
top: 1.5rem;
right: 1.5rem;
z-index: 110;
padding: 6px;
background: rgba(40, 40, 38, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
:global(html:not(.dark)) .navbar-mobile,
:global(html:not(.dark)) .navbar-desktop {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.navbar-desktop-inner {
......@@ -201,16 +243,16 @@
align-items: center;
gap: 10px;
padding: 7px 12px 7px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #9B9890;
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-search-btn:hover {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.15);
border-color: rgba(201, 167, 81, 0.4);
color: #F5F0E8;
}
......@@ -252,28 +294,28 @@
align-items: center;
justify-content: center;
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #9B9890;
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.15);
border-color: rgba(201, 167, 81, 0.4);
color: #F5F0E8;
}
:global(html:not(.dark)) .nav-btn-icon {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.12);
color: #666;
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.08);
color: #555;
}
:global(html:not(.dark)) .nav-btn-icon:hover {
background: rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.1);
border-color: rgba(201, 167, 81, 0.5);
color: #1A1A18;
}
......@@ -285,23 +327,66 @@
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8);
}
/* Home link - hereda estilos de nav-btn-icon */
.nav-home-link {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.nav-home-link:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(201, 167, 81, 0.4);
color: #F5F0E8;
}
:global(html:not(.dark)) .nav-home-link {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.08);
color: #555;
}
:global(html:not(.dark)) .nav-home-link:hover {
background: rgba(0, 0, 0, 0.1);
border-color: rgba(201, 167, 81, 0.5);
color: #1A1A18;
}
/* Mobile icon buttons */
.nav-btn-icon-mobile {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #9B9890;
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-btn-icon-mobile:hover {
background: rgba(255, 255, 255, 0.18);
color: #F5F0E8;
}
:global(html:not(.dark)) .nav-btn-icon-mobile {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.12);
color: #666;
background: rgba(0, 0, 0, 0.06);
border-color: rgba(0, 0, 0, 0.06);
color: #555;
}
:global(html:not(.dark)) .nav-btn-icon-mobile:hover {
background: rgba(0, 0, 0, 0.12);
color: #1A1A18;
}
</style>
......
......@@ -24,7 +24,10 @@
let searchFilters = $state({
entidad: true,
objeto_gasto: true,
rubro: true
rubro: true,
finfun: true,
organismo: true,
fuente: true
});
// Filtered results based on active filters
......@@ -90,6 +93,12 @@
goto(`/objeto/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
} else if (item.tipo === 'finfun') {
goto(`/finfun/${item.codigo}`);
} else if (item.tipo === 'organismo') {
goto(`/organismo/${item.codigo}`);
} else if (item.tipo === 'fuente') {
goto(`/fuente/${item.codigo}`);
}
}
......@@ -99,11 +108,38 @@
}
}
// Type labels and colors
// Type labels, colors and descriptions
const typeConfig = {
entidad: { label: 'Institución', color: '#C9A751' },
objeto_gasto: { label: 'Objeto de gasto', color: '#6B9F78' },
rubro: { label: 'Rubro', color: '#7B8EC9' }
entidad: {
label: 'Institución',
color: '#C9A751',
description: 'Ministerios, municipios, universidades...'
},
objeto_gasto: {
label: 'Objeto de gasto',
color: '#6B9F78',
description: 'Sueldos, viáticos, servicios básicos...'
},
rubro: {
label: 'Rubro',
color: '#7B8EC9',
description: 'Impuestos, tasas, transferencias...'
},
finfun: {
label: 'Finalidad',
color: '#C97B8E',
description: 'Manufacturas, construcción, transporte...'
},
organismo: {
label: 'Organismo',
color: '#9B7BC9',
description: 'Tipo de entidad: central, descentralizada...'
},
fuente: {
label: 'Fuente',
color: '#7BC9A1',
description: 'Origen del financiamiento: TGN, créditos...'
}
};
</script>
......@@ -129,32 +165,85 @@
</button>
</div>
<!-- Filters -->
<!-- Filters section with explicit descriptions -->
<div class="search-modal-filters">
<div class="filters-header">
<span class="filters-label">Buscar en:</span>
</div>
<div class="filters-grid">
<button
class="search-filter-chip"
class="search-filter-card"
class:filter-active={searchFilters.entidad}
onclick={() => toggleFilter('entidad')}
>
<div class="filter-card-header">
<span class="filter-dot" style="background:{typeConfig.entidad.color}"></span>
Instituciones
<span class="filter-title">Instituciones</span>
<span class="filter-check">{searchFilters.entidad ? '✓' : ''}</span>
</div>
<span class="filter-desc">{typeConfig.entidad.description}</span>
</button>
<button
class="search-filter-chip"
class="search-filter-card"
class:filter-active={searchFilters.objeto_gasto}
onclick={() => toggleFilter('objeto_gasto')}
>
<div class="filter-card-header">
<span class="filter-dot" style="background:{typeConfig.objeto_gasto.color}"></span>
Objetos de gasto
<span class="filter-title">Gastos</span>
<span class="filter-check">{searchFilters.objeto_gasto ? '✓' : ''}</span>
</div>
<span class="filter-desc">{typeConfig.objeto_gasto.description}</span>
</button>
<button
class="search-filter-chip"
class="search-filter-card"
class:filter-active={searchFilters.rubro}
onclick={() => toggleFilter('rubro')}
>
<div class="filter-card-header">
<span class="filter-dot" style="background:{typeConfig.rubro.color}"></span>
Rubros
<span class="filter-title">Ingresos</span>
<span class="filter-check">{searchFilters.rubro ? '✓' : ''}</span>
</div>
<span class="filter-desc">{typeConfig.rubro.description}</span>
</button>
<button
class="search-filter-card"
class:filter-active={searchFilters.finfun}
onclick={() => toggleFilter('finfun')}
>
<div class="filter-card-header">
<span class="filter-dot" style="background:{typeConfig.finfun.color}"></span>
<span class="filter-title">Finalidad</span>
<span class="filter-check">{searchFilters.finfun ? '✓' : ''}</span>
</div>
<span class="filter-desc">{typeConfig.finfun.description}</span>
</button>
<button
class="search-filter-card"
class:filter-active={searchFilters.organismo}
onclick={() => toggleFilter('organismo')}
>
<div class="filter-card-header">
<span class="filter-dot" style="background:{typeConfig.organismo.color}"></span>
<span class="filter-title">Organismos</span>
<span class="filter-check">{searchFilters.organismo ? '✓' : ''}</span>
</div>
<span class="filter-desc">{typeConfig.organismo.description}</span>
</button>
<button
class="search-filter-card"
class:filter-active={searchFilters.fuente}
onclick={() => toggleFilter('fuente')}
>
<div class="filter-card-header">
<span class="filter-dot" style="background:{typeConfig.fuente.color}"></span>
<span class="filter-title">Fuentes</span>
<span class="filter-check">{searchFilters.fuente ? '✓' : ''}</span>
</div>
<span class="filter-desc">{typeConfig.fuente.description}</span>
</button>
</div>
</div>
<!-- Results -->
......@@ -164,15 +253,7 @@
{:else if $isLoading}
<div class="search-modal-status">Buscando...</div>
{:else if searchVal.length < 2}
<div class="search-modal-hint">
<span class="hint-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4M12 8h.01"/>
</svg>
</span>
<span>Escribe al menos 2 caracteres para buscar</span>
</div>
<div class="search-modal-empty"></div>
{:else if filteredResults.length === 0}
<div class="search-modal-status">Sin resultados para "{searchVal}"</div>
{:else}
......@@ -218,9 +299,7 @@
.search-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: flex-start;
......@@ -231,13 +310,27 @@
.search-modal {
width: 100%;
max-width: 580px;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
max-width: 620px;
background: rgba(40, 40, 38, 0.75);
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
0 1px 0 rgba(255, 255, 255, 0.1) inset;
overflow: hidden;
animation: slideIn 0.2s ease-out;
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
:global(html:not(.dark)) .search-modal {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(0, 0, 0, 0.08);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.5) inset,
0 1px 0 rgba(255, 255, 255, 0.8) inset;
}
@keyframes fadeIn {
......@@ -248,7 +341,7 @@
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.98);
transform: translateY(-8px) scale(0.96);
}
to {
opacity: 1;
......@@ -261,8 +354,12 @@
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-borde);
padding: 18px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
:global(html:not(.dark)) .search-modal-header {
border-bottom-color: rgba(0, 0, 0, 0.08);
}
.search-modal-icon {
......@@ -297,56 +394,125 @@
.search-modal-close kbd {
font-family: inherit;
font-size: 0.6875rem;
padding: 4px 8px;
background: var(--theme-fill);
border: 1px solid var(--theme-borde);
padding: 5px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: var(--theme-texto);
opacity: 0.7;
transition: opacity 0.15s;
opacity: 0.8;
transition: all 0.15s;
}
:global(html:not(.dark)) .search-modal-close kbd {
background: rgba(0, 0, 0, 0.06);
border-color: rgba(0, 0, 0, 0.12);
}
.search-modal-close:hover kbd {
opacity: 1;
background: rgba(255, 255, 255, 0.15);
}
:global(html:not(.dark)) .search-modal-close:hover kbd {
background: rgba(0, 0, 0, 0.1);
}
/* Filters */
.search-modal-filters {
display: flex;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
:global(html:not(.dark)) .search-modal-filters {
border-bottom-color: rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
}
.filters-header {
margin-bottom: 8px;
}
.filters-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--theme-texto);
opacity: 0.5;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--theme-borde);
background: var(--theme-fill);
}
.search-filter-chip {
.search-filter-card {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 20px;
font-size: 0.8125rem;
color: var(--theme-texto);
flex-direction: column;
gap: 3px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
transition: all 0.2s ease;
text-align: left;
opacity: 0.5;
}
.search-filter-chip:hover {
:global(html:not(.dark)) .search-filter-card {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.1);
}
.search-filter-card:hover {
opacity: 0.8;
background: rgba(255, 255, 255, 0.08);
}
.search-filter-chip.filter-active {
:global(html:not(.dark)) .search-filter-card:hover {
background: rgba(0, 0, 0, 0.06);
}
.search-filter-card.filter-active {
opacity: 1;
border-color: var(--theme-texto);
border-color: rgba(201, 167, 81, 0.5);
background: rgba(201, 167, 81, 0.1);
}
.filter-card-header {
display: flex;
align-items: center;
gap: 6px;
}
.filter-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.filter-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
flex: 1;
}
.filter-check {
font-size: 0.6875rem;
color: #C9A751;
font-weight: 600;
}
.filter-desc {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.6;
line-height: 1.3;
}
/* Results */
......@@ -355,25 +521,15 @@
overflow-y: auto;
}
.search-modal-status,
.search-modal-hint {
.search-modal-status {
padding: 24px 20px;
text-align: center;
color: var(--theme-texto);
font-size: 0.875rem;
}
.search-modal-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0.6;
}
.hint-icon {
display: flex;
opacity: 0.7;
.search-modal-empty {
padding: 16px;
}
.search-result-item {
......@@ -385,10 +541,14 @@
padding: 12px 20px;
background: none;
border: none;
border-top: 1px solid var(--theme-borde);
border-top: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
text-align: left;
transition: background 0.1s;
transition: background 0.15s ease;
}
:global(html:not(.dark)) .search-result-item {
border-top-color: rgba(0, 0, 0, 0.06);
}
.search-result-item:first-child {
......@@ -397,7 +557,12 @@
.search-result-item:hover,
.search-result-item.result-selected {
background: var(--theme-fill);
background: rgba(255, 255, 255, 0.08);
}
:global(html:not(.dark)) .search-result-item:hover,
:global(html:not(.dark)) .search-result-item.result-selected {
background: rgba(0, 0, 0, 0.05);
}
.result-main {
......@@ -437,10 +602,14 @@
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.5;
padding: 4px 8px;
background: var(--theme-fill);
border-radius: 4px;
opacity: 0.6;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
:global(html:not(.dark)) .result-code {
background: rgba(0, 0, 0, 0.06);
}
/* Footer */
......@@ -448,8 +617,13 @@
display: flex;
gap: 16px;
padding: 12px 20px;
border-top: 1px solid var(--theme-borde);
background: var(--theme-fill);
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
:global(html:not(.dark)) .search-modal-footer {
border-top-color: rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
}
.footer-hint {
......@@ -464,31 +638,53 @@
.footer-hint kbd {
font-family: inherit;
font-size: 0.625rem;
padding: 2px 5px;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
padding: 3px 6px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
:global(html:not(.dark)) .footer-hint kbd {
background: rgba(0, 0, 0, 0.06);
border-color: rgba(0, 0, 0, 0.1);
}
/* Responsive */
@media (max-width: 640px) {
.search-modal-backdrop {
padding: 0;
align-items: flex-end;
background: rgba(0, 0, 0, 0.4);
}
.search-modal {
max-width: 100%;
border-radius: 20px 20px 0 0;
max-height: 85vh;
max-height: 90vh;
border-bottom: none;
}
.search-modal-results {
max-height: 50vh;
max-height: 45vh;
}
.search-modal-filters {
flex-wrap: wrap;
.filters-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.search-filter-card {
flex-direction: row;
align-items: center;
padding: 10px 14px;
}
.filter-card-header {
flex: 1;
}
.filter-desc {
display: none;
}
.search-modal-footer {
......
......@@ -61,6 +61,43 @@ export async function initSearchIndex() {
throw errorRubros;
}
// Cargar finalidad y función
const { data: finfun, error: errorFinfun } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('finfun, desc_finfun, nivel');
if (errorFinfun) {
console.error('Error cargando finalidad/función:', errorFinfun);
throw errorFinfun;
}
// Cargar organismos (no bloquea si falla)
let organismos = [];
const { data: organismosData, error: errorOrganismos } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('organismo, organismo_desc_organismo, organismo_grupo, organismo_sub_grupo');
if (errorOrganismos) {
console.warn('Tabla clas_organismos no disponible:', errorOrganismos.message);
} else {
organismos = organismosData || [];
}
// Cargar fuentes (no bloquea si falla)
let fuentes = [];
const { data: fuentesData, error: errorFuentes } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('fuente, desc_fuente, grupo_fuente, desc_grupo_fuente');
if (errorFuentes) {
console.warn('Tabla clas_fuentes no disponible:', errorFuentes.message);
} else {
fuentes = fuentesData || [];
}
// Transformar entidades para el índice
const entidadesIndex = entidades.map(item => ({
tipo: 'entidad',
......@@ -102,8 +139,49 @@ export async function initSearchIndex() {
codigo_normalizado: item.rubro?.toString() || ''
}));
// Transformar finalidad y función para el índice
const nivelLabelsFinfun = { finalidad: 'Finalidad', grpfuncion: 'Grupo Función', funcion: 'Función' };
const finfunIndex = finfun.map(item => ({
tipo: 'finfun',
codigo: item.finfun,
nombre: item.desc_finfun,
sigla: null,
contexto: `Finalidad y Función · ${nivelLabelsFinfun[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_finfun),
sigla_normalizada: '',
codigo_normalizado: item.finfun?.toString() || ''
}));
// Transformar organismos para el índice
// Todos los registros en la tabla son organismos (nivel hoja)
const organismosIndex = organismos.map(item => ({
tipo: 'organismo',
codigo: item.organismo,
nombre: item.organismo_desc_organismo,
sigla: null,
contexto: `Organismo · Grupo ${item.organismo_grupo}`,
nivel: 'organismo',
nombre_normalizado: normalizeText(item.organismo_desc_organismo),
sigla_normalizada: '',
codigo_normalizado: item.organismo?.toString() || ''
}));
// Transformar fuentes para el índice
const fuentesIndex = fuentes.map(item => ({
tipo: 'fuente',
codigo: item.fuente,
nombre: item.desc_fuente,
sigla: null,
contexto: `Fuente · ${item.desc_grupo_fuente}`,
nivel: 'fuente',
nombre_normalizado: normalizeText(item.desc_fuente),
sigla_normalizada: '',
codigo_normalizado: item.fuente?.toString() || ''
}));
// Combinar todos los conjuntos
searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex];
searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex, ...finfunIndex, ...organismosIndex, ...fuentesIndex];
fuseInstance = new Fuse(searchData, fuseOptions);
......
......@@ -8,7 +8,7 @@ export const isLoading = writable(false);
export const indexLoaded = writable(false);
export const error = writable(null);
export const selectedIndex = writable(-1);
export const filterType = writable('todos'); // todos, entidad, objeto_gasto, rubro
export const filterType = writable('todos'); // todos, entidad, objeto_gasto, rubro, finfun
// Estado derivado: si está mostrando resultados
export const showResults = derived(
......
......@@ -294,9 +294,9 @@
<!-- Bloque 1: Identidad -->
<div class="hero-identity">
<h1 class="hero-title anim" style="--d:200ms">Presupuesto <span class="gold">Abierto</span></h1>
<p class="hero-sub anim" style="--d:340ms">Información diaria e histórica de todo el Estado Boliviano.</p>
<p class="hero-sub anim" style="--d:340ms"><a href="/vista-diaria" class="hero-sub-link">Información diaria</a> e histórica de todo el Estado Boliviano.</p>
<div class="hero-stats anim" style="--d:420ms">
<span class="stat-num">21</span> años<span class="stat-dot">·</span><span class="stat-num">8.3M</span> registros<span class="stat-dot">·</span><span class="stat-num">647</span> instituciones<span class="stat-dot">·</span><span class="stat-num">7</span> clasificadores<span class="stat-dot">·</span><span class="stat-num">10</span> visualizaciones<span class="stat-dot">·</span><span class="stat-num">4</span> historias
<span class="stat-num">21</span> años<span class="stat-dot">·</span><span class="stat-num">8.3M</span> registros<span class="stat-dot">·</span><span class="stat-num">647</span> instituciones<span class="stat-dot">·</span><span class="stat-num">7</span> <a href="#clasificadores" class="stat-link">clasificadores</a><span class="stat-dot">·</span><span class="stat-num">10</span> <a href="#visualizaciones" class="stat-link">visualizaciones</a><span class="stat-dot">·</span><span class="stat-num">4</span> <a href="#historias" class="stat-link">historias</a>
</div>
</div>
......@@ -305,20 +305,20 @@
<div class="search-wrap">
<!-- Search bar unificado -->
<div class="search-bar" class:search-focused={searchFocused} class:search-has-results={searchFocused && $query.length >= 2 && ($results.length > 0 || $results.length === 0 || $isLoading)} style="--accent:{accent}">
<!-- Toggle integrado (desktop) -->
<div class="toggle-bar toggle-desktop">
<button class="toggle-btn" class:toggle-active={mode === 'archivo'} on:click={() => switchMode('archivo')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={mode === 'archivo' ? '#C9A751' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<!-- Link al diario + Badge informativo (desktop) -->
<div class="search-meta toggle-desktop">
<a href="/vista-diaria" class="search-meta-item search-diario-link">
Ver gasto de hoy
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
2005 a 2025
</button>
<button class="toggle-btn" class:toggle-active={mode === 'diario'} on:click={() => switchMode('diario')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={mode === 'diario' ? '#4A8B6E' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 12 7 12 9 6 12 18 15 9 17 12 21 12"/>
</a>
<span class="search-meta-item search-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
Diario
</button>
Viendo 2005 – 2025
</span>
</div>
<div class="search-divider"></div>
......@@ -451,21 +451,19 @@
<!-- Mobile settings panel -->
<div class="mobile-settings-panel" class:mobile-settings-panel-open={mobileSettingsOpen}>
<div class="mobile-toggle-bar">
<button class="mobile-toggle-btn" class:toggle-active={mode === 'archivo'} on:click={() => { switchMode('archivo'); mobileSettingsOpen = false; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={mode === 'archivo' ? '#C9A751' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<div class="mobile-meta-bar">
<a href="/vista-diaria" class="mobile-meta-item mobile-diario-link">
Ver hoy
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
<span>Histórico</span>
<span class="mobile-toggle-sub">2005 — 2025</span>
</button>
<button class="mobile-toggle-btn" class:toggle-active={mode === 'diario'} on:click={() => { switchMode('diario'); mobileSettingsOpen = false; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={mode === 'diario' ? '#4A8B6E' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 12 7 12 9 6 12 18 15 9 17 12 21 12"/>
</a>
<span class="mobile-meta-item mobile-search-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span>Diario</span>
<span class="mobile-toggle-sub">En vivo</span>
</button>
Viendo 2005 – 2025
</span>
</div>
<div class="mobile-filter-section">
<div class="mobile-filter-title">Buscar</div>
......@@ -944,20 +942,27 @@
.hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD}
.hero-title{font-size:clamp(44px,5.5vw,72px);font-weight:900;line-height:1.02;letter-spacing:-0.035em;max-width:720px;margin:14px auto 0}
.hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#A5A29A;max-width:100%;margin-top:14px;line-height:1.6}
.hero-sub-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(165,162,154,0.5);transition:all 0.2s;padding-bottom:1px}
.hero-sub-link:hover{color:#4A8B6E;border-bottom-color:#4A8B6E}
.hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1}
.stat-num{color:#F5F0E8}
.stat-dot{margin:0 10px;opacity:0.3}
.stat-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(184,181,173,0.4);transition:all 0.2s;padding-bottom:1px}
.stat-link:hover{color:#C9A751;border-bottom-color:#C9A751}
/* Search */
.search-wrap{max-width:860px;width:100%;margin:0 auto;position:relative;z-index:9999}
.search-wrap{max-width:960px;width:100%;margin:0 auto;position:relative;z-index:9999}
.search-bar{display:flex;align-items:center;background:rgba(255,255,255,0.04);border-radius:16px;padding:8px 14px 8px 8px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1);box-shadow:0 0 0 1px rgba(201,167,81,0.12),0 8px 32px rgba(0,0,0,0.25);position:relative}
.search-focused{background:rgba(255,255,255,0.07);box-shadow:0 0 0 1.5px rgba(201,167,81,0.35),0 8px 40px rgba(0,0,0,0.3)}
.search-has-results{border-radius:16px 16px 0 0}
.toggle-bar{display:flex;border-radius:10px;padding:2px;background:rgba(0,0,0,0.2);flex-shrink:0}
.toggle-btn{background:transparent;border:none;border-radius:8px;padding:12px 16px;cursor:pointer;display:flex;align-items:center;gap:7px;transition:all 0.3s;font-family:var(--sans);font-size:14px;font-weight:400;color:#9B9890}
.toggle-btn svg{display:block;flex-shrink:0}
.toggle-btn:hover{color:#B8B5AD}
.toggle-active{background:rgba(255,255,255,0.1);font-weight:600;color:#F5F0E8}
.search-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;padding:0 4px}
.search-meta-item{display:flex;align-items:center;gap:7px;font-family:var(--sans);font-size:13px;font-weight:500;padding:10px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
.search-meta-item svg{display:block;flex-shrink:0}
.search-badge{color:#C9A751;background:rgba(201,167,81,0.12)}
.search-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)}
.search-diario-link:hover{background:rgba(74,139,110,0.22);color:#5AA080}
.search-diario-link svg{transition:transform 0.2s}
.search-diario-link:hover svg{transform:translateX(3px)}
.search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0}
.search-icon{flex-shrink:0;transition:stroke 0.3s}
.search-input-wrap{flex:1;position:relative;display:flex;align-items:center}
......@@ -995,12 +1000,12 @@
/* Mobile settings panel (hidden on desktop) */
.mobile-settings-panel{display:none;overflow:hidden;max-height:0;opacity:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
.mobile-settings-panel-open{max-height:300px;opacity:1;margin-top:12px}
.mobile-toggle-bar{display:flex;gap:10px}
.mobile-toggle-btn{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:16px 12px;border-radius:12px;background:rgba(0,0,0,0.2);border:1px solid rgba(255,255,255,0.06);cursor:pointer;transition:all 0.3s}
.mobile-toggle-btn.toggle-active{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.15)}
.mobile-toggle-btn span{font-family:var(--sans);font-size:14px;font-weight:600;color:#F5F0E8}
.mobile-toggle-btn svg{margin-bottom:4px}
.mobile-toggle-sub{font-size:11px!important;font-weight:400!important;color:#9B9890!important;font-family:var(--mono)!important;letter-spacing:0.05em}
.mobile-meta-bar{display:flex;gap:10px;padding:4px 0}
.mobile-meta-item{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--sans);font-size:13px;font-weight:500;padding:12px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
.mobile-meta-item svg{flex-shrink:0}
.mobile-search-badge{color:#C9A751;background:rgba(201,167,81,0.12)}
.mobile-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)}
.mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(74,139,110,0.22)}
.mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)}
.mobile-filter-title{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:0.1em;color:#6B6860;text-transform:uppercase;margin-bottom:8px}
.mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap}
......@@ -1337,8 +1342,7 @@
/* Search */
.search-wrap{padding:0}
.mobile-settings-btn{width:36px;height:36px}
.mobile-toggle-btn{padding:14px 10px}
.mobile-toggle-btn span{font-size:13px}
.mobile-meta-item{padding:10px 12px;font-size:12px}
.search-results{max-height:220px}
.search-result-item{padding:10px 14px}
.result-name{font-size:12px}
......
<script>
me pare<script>
import { supabase } from '$lib/supabase';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
......@@ -79,17 +79,24 @@
'10': 'Protección Social',
};
// Función para obtener color según la finalidad (primer dígito del código finfun)
// Helper para obtener la finalidad de un código (maneja caso especial de "10")
function getFinalidadKey(finfun) {
const code = String(finfun);
if (code.startsWith('10')) return '10';
return code.charAt(0);
}
// Función para obtener color según la finalidad
function getNodeColor(finfun, opacity = 1) {
const finalidadDigit = String(finfun).charAt(0);
const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
const finalidadKey = getFinalidadKey(finfun);
const baseColor = FINALIDAD_COLORS[finalidadKey] || '#888888';
if (opacity === 1) return baseColor;
return d3.color(baseColor).copy({opacity}).formatRgb();
}
function getTextColor(finfun) {
const finalidadDigit = String(finfun).charAt(0);
const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
const finalidadKey = getFinalidadKey(finfun);
const baseColor = FINALIDAD_COLORS[finalidadKey] || '#888888';
const color = d3.color(baseColor);
const r = color.r / 255;
const g = color.g / 255;
......@@ -99,8 +106,8 @@
}
function getTextColorSecondary(finfun) {
const finalidadDigit = String(finfun).charAt(0);
const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
const finalidadKey = getFinalidadKey(finfun);
const baseColor = FINALIDAD_COLORS[finalidadKey] || '#888888';
const color = d3.color(baseColor);
const r = color.r / 255;
const g = color.g / 255;
......@@ -610,13 +617,26 @@
});
// Helpers para derivar jerarquía desde código finfun
// Jerarquía: Finalidad (1 dígito) → Grupo Función (2 dígitos) → Función (3+ dígitos)
// 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"
function getFinalidadFromFinfun(finfun) {
return finfun.charAt(0);
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);
}
function getGrpFuncionFromFinfun(finfun) {
return finfun.substring(0, 2);
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 getItemsForFinalidad(finalidadCode) {
......
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
let loading = $state(true);
let searchQuery = $state('');
let grupos = $state([]);
let allItems = $state([]);
let selectedGrupo = $state(null);
let selectedItem = $state(null);
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
onMount(async () => {
// Forzar recálculo de layout para navegación cliente
requestAnimationFrame(() => {
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('*')
.order('fuente');
if (!error && data) {
allItems = data;
// Crear grupos sintéticos desde valores únicos de grupo_fuente
const gruposUnicos = [...new Set(data.map(item => item.grupo_fuente))].filter(g => g != null);
grupos = gruposUnicos.map(grupoNum => {
const sample = data.find(item => item.grupo_fuente === grupoNum);
return {
grupo_fuente: grupoNum,
desc_grupo_fuente: sample?.desc_grupo_fuente || `Grupo ${grupoNum}`
};
}).sort((a, b) => Number(a.grupo_fuente) - Number(b.grupo_fuente));
if (grupos.length > 0) {
selectedGrupo = grupos[0];
}
}
loading = false;
});
// Obtener fuentes para un grupo específico
function getFuentesForGrupo(grupo) {
if (!grupo) return [];
return allItems
.filter(item => item.grupo_fuente === grupo.grupo_fuente)
.sort((a, b) => String(a.fuente).localeCompare(String(b.fuente)));
}
// Búsqueda global
function searchGlobal(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allItems
.filter(item =>
item.fuente?.toString().includes(q) ||
item.desc_fuente?.toLowerCase().includes(q) ||
item.desc_grupo_fuente?.toLowerCase().includes(q)
)
.slice(0, 20);
}
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
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)));
}
function selectGrupo(grupo) {
selectedGrupo = grupo;
selectedItem = null;
searchQuery = '';
highlightedItem = null;
sidebarOpen = false;
}
function openDetail(item) {
selectedItem = item;
}
function closeDetail() {
selectedItem = null;
}
function goToSearchResult(item) {
const targetGrupo = grupos.find(g => g.grupo_fuente === item.grupo_fuente);
if (targetGrupo) {
selectedGrupo = targetGrupo;
highlightedItem = item.fuente;
searchQuery = '';
sidebarOpen = false;
setTimeout(() => {
const element = document.getElementById(`f-${item.fuente}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
setTimeout(() => {
highlightedItem = null;
}, 2000);
}
}
let grupoContent = $derived(selectedGrupo ? getFuentesForGrupo(selectedGrupo) : []);
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
// Contadores
let totalItems = $derived(allItems.length);
let totalGrupos = $derived(grupos.length);
</script>
<svelte:head>
<title>Fuentes de Financiamiento | Presupuesto Público</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" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo); transition: background-color 0.2s, color 0.2s;">
<!-- 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 py-6">
<!-- Breadcrumb: responsive -->
<nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<!-- Móvil: solo padre -->
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
</a>
<!-- Desktop: ruta completa -->
<div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
<a href="/" class="transition-colors hover:opacity-80">Inicio</a>
<span style="opacity: 0.5;">/</span>
<a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
</div>
</nav>
<div class="max-w-3xl">
<p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Clasificador 02
</p>
<h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
¿Con qué recursos se financia?
</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Organiza los recursos según su <strong style="color: var(--theme-titulo);">origen de financiamiento</strong>:
Tesoro General, recursos específicos, créditos externos e internos, donaciones.
Permite entender de dónde provienen los fondos para el gasto público.
</p>
{#if totalItems > 0}
<p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> fuentes de financiamiento en
<span style="color: var(--theme-titulo); font-weight: 500;">{totalGrupos}</span> grupos
</p>
{/if}
<!-- Jerarquía -->
<div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
<span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
Grupo
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
Fuente
</span>
</div>
<!-- Versión móvil simplificada -->
<p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Jerarquía: Grupo → Fuente
</p>
</div>
</div>
</header>
{#if loading}
<div class="flex items-center justify-center py-20">
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
</div>
{:else}
<!-- Botón móvil para abrir sidebar -->
<div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
<button
onclick={() => sidebarOpen = !sidebarOpen}
class="flex items-center gap-2 text-sm"
style="color: var(--theme-texto);"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="font-medium">{selectedGrupo ? selectedGrupo.desc_grupo_fuente : 'Seleccionar grupo'}</span>
<svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
<!-- Sidebar izquierda: Grupos -->
{#if sidebarOpen}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/30 z-40 lg:hidden"
onclick={() => sidebarOpen = false}
></div>
{/if}
<aside class="
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
lg:translate-x-0
fixed lg:relative
inset-y-0 left-0
w-72 lg:w-64
z-50 lg:z-auto
transition-transform duration-200 ease-in-out
lg:flex-shrink-0
shadow-xl lg:shadow-none
sidebar-left
">
<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">
<!-- 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 fuentes</span>
<button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Buscador -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar..."
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
/>
</div>
<!-- Resultados de búsqueda -->
{#if isSearching}
<div class="mb-4">
<p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
{searchResults.length} resultados
</p>
<div class="space-y-1">
{#each searchResults as result}
<button
class="w-full text-left px-2 py-2 text-sm rounded transition-all"
style="color: var(--theme-titulo);"
onclick={() => goToSearchResult(result)}
>
<span class="text-xs block" style="color: var(--theme-texto);">Fuente</span>
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.fuente}</span>
<span class="ml-1">{result.desc_fuente}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
{/if}
</div>
</div>
{:else}
<!-- Lista de grupos -->
<nav>
<p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
<ul class="space-y-1">
{#each grupos as grupo}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
style="{selectedGrupo?.grupo_fuente === grupo.grupo_fuente
? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 2px solid var(--theme-accent);`
: `color: var(--theme-texto);`}"
onclick={() => selectGrupo(grupo)}
>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">Grupo {grupo.grupo_fuente}</span>
{grupo.desc_grupo_fuente}
</button>
</li>
{/each}
</ul>
</nav>
{/if}
</div>
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
<div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{#if selectedGrupo}
{#key selectedGrupo.grupo_fuente}
<div in:fade={{ duration: 200, delay: 50 }}>
<!-- Título del grupo -->
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">Grupo {selectedGrupo.grupo_fuente}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
{selectedGrupo.desc_grupo_fuente}
</h2>
<p class="text-sm" style="color: var(--theme-texto);">
{grupoContent.length} fuentes de financiamiento en este grupo
</p>
</div>
<!-- Fuentes -->
<div class="space-y-8">
{#each grupoContent as fuente}
{@const fuenteDescs = parseDescripciones(fuente.descripciones)}
<section
id="f-{fuente.fuente}"
class="scroll-mt-4 {highlightedItem === fuente.fuente ? 'highlighted' : ''}"
>
<div class="flex items-start gap-2 sm:gap-4 mb-3">
<span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{fuente.fuente}</span>
<div class="flex-1">
<h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{fuente.desc_fuente}</span>
{#if fuente.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(fuente)}
title="Ver variaciones de descripción"
>
{fuente.n_variaciones} var.
</button>
{/if}
<a
href="/fuente/{fuente.fuente}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h3>
{#if fuenteDescs.length > 0}
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{fuenteDescs[0].descripcion}</p>
{/if}
<!-- Vigencia temporal -->
{#if fuente.gestiones}
<p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
Vigente: {fuente.gestiones}
</p>
{/if}
</div>
</div>
</section>
{/each}
</div>
</div>
{/key}
{/if}
</div>
</main>
<!-- Sidebar derecha: En esta página -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
{#if selectedGrupo && !isSearching}
<nav class="space-y-2">
{#each grupoContent as fuente}
<a
href="#f-{fuente.fuente}"
class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="{fuente.desc_fuente}"
>
<span class="font-mono text-xs" style="opacity: 0.6;">{fuente.fuente}</span>
{fuente.desc_fuente}
</a>
{/each}
</nav>
{/if}
</div>
</aside>
</div>
{/if}
</div>
<!-- Modal de detalle -->
{#if selectedItem}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
onclick={closeDetail}
>
<div
transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
style="background-color: var(--theme-surface);"
onclick={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<div>
<p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">Fuente de Financiamiento</p>
<h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
<span class="font-mono" style="color: var(--theme-texto);">{selectedItem.fuente}</span>
<span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
{selectedItem.desc_fuente}
</h3>
</div>
<button
onclick={closeDetail}
class="text-2xl leading-none"
style="color: var(--theme-texto);"
>
&times;
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
{#if selectedItem.n_variaciones > 1}
Descripciones ({selectedItem.n_variaciones} variaciones)
{:else}
Descripción
{/if}
</h4>
<div class="space-y-4">
{#each parseDescripciones(selectedItem.descripciones) as desc, i}
<div class="border-l-2 pl-4 py-3 rounded-r" style="{i === 0 ? `border-color: var(--theme-accent); background-color: color-mix(in srgb, var(--theme-accent) 10%, transparent);` : `border-color: var(--theme-borde);`}">
<p class="text-sm mb-2" style="color: var(--theme-texto);">
{#if i === 0 && selectedItem.n_variaciones > 1}
<span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
<span class="mx-1" style="opacity: 0.5;">·</span>
{/if}
<span class="font-mono">{desc.rangos}</span>
</p>
<p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
</div>
{/each}
</div>
<!-- Vigencia temporal -->
{#if selectedItem.gestiones}
<div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
<p class="text-xs font-mono" style="color: var(--theme-texto);">
<span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
/* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
.sidebar-left {
background-color: var(--theme-surface);
}
@media (min-width: 1024px) {
.sidebar-left {
background-color: transparent;
}
}
/* Highlight pulse animation para resultados de búsqueda */
@keyframes highlight-pulse {
0%, 100% {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent);
}
50% {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--theme-accent) 50%, transparent);
}
}
.highlighted {
animation: highlight-pulse 0.8s ease-in-out 2;
border-radius: 0.5rem;
padding: 0.5rem;
margin-left: -0.5rem;
}
/* Layout principal - fallback nativo */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
/* Sidebar - fallback para responsive */
.sidebar-left {
position: fixed;
transform: translateX(-100%);
}
@media (min-width: 1024px) {
.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0;
width: 16rem;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
}
</style>
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
let loading = $state(true);
let searchQuery = $state('');
let grupos = $state([]);
let allItems = $state([]);
let selectedGrupo = $state(null);
let selectedItem = $state(null);
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
onMount(async () => {
requestAnimationFrame(() => {
document.body.offsetHeight;
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('*')
.order('organismo');
if (!error && data) {
allItems = data;
// Extraer grupos únicos desde organismo_grupo
const gruposUnicos = [...new Set(data.map(d => d.organismo_grupo))].filter(g => g != null);
grupos = gruposUnicos.map(grupoNum => {
const sample = data.find(d => d.organismo_grupo === grupoNum);
return {
organismo_grupo: grupoNum,
organismo_desc_grupo: sample?.organismo_desc_grupo || `Grupo ${grupoNum}`,
organismo_sigla_grupo: sample?.organismo_sigla_grupo || ''
};
}).sort((a, b) => Number(a.organismo_grupo) - Number(b.organismo_grupo));
if (grupos.length > 0) {
selectedGrupo = grupos[0];
}
}
loading = false;
});
// Helper para obtener subgrupos de un grupo
function getItemsForGrupo(grupo) {
if (!grupo) return { subgrupos: [] };
const grupoNum = grupo.organismo_grupo;
const itemsDelGrupo = allItems.filter(item => item.organismo_grupo === grupoNum);
const subgruposUnicos = [...new Set(itemsDelGrupo.map(i => i.organismo_sub_grupo))].filter(s => s != null);
const subgrupos = subgruposUnicos.map(subgrupoNum => {
const itemsDelSubgrupo = itemsDelGrupo.filter(i => i.organismo_sub_grupo === subgrupoNum);
const sample = itemsDelSubgrupo[0];
return {
organismo_sub_grupo: subgrupoNum,
organismo_desc_sub_grupo: sample?.organismo_desc_sub_grupo || `Subgrupo ${subgrupoNum}`,
organismo_sigla_sub_grupo: sample?.organismo_sigla_sub_grupo || '',
organismo_grupo: grupoNum,
organismos: itemsDelSubgrupo.sort((a, b) => String(a.organismo).localeCompare(String(b.organismo)))
};
}).sort((a, b) => Number(a.organismo_sub_grupo) - Number(b.organismo_sub_grupo));
return { subgrupos };
}
// Búsqueda global
function searchGlobal(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allItems
.filter(item =>
item.organismo?.toString().toLowerCase().includes(q) ||
item.organismo_desc_organismo?.toLowerCase().includes(q)
)
.slice(0, 20);
}
function selectGrupo(grupo) {
selectedGrupo = grupo;
searchQuery = '';
highlightedItem = null;
sidebarOpen = false;
}
function openDetail(item) {
selectedItem = item;
}
function closeDetail() {
selectedItem = null;
}
function goToSearchResult(item) {
const targetGrupo = grupos.find(g => g.organismo_grupo === item.organismo_grupo);
if (targetGrupo) {
selectedGrupo = targetGrupo;
highlightedItem = item.organismo;
searchQuery = '';
sidebarOpen = false;
setTimeout(() => {
const element = document.getElementById(`org-${item.organismo}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
setTimeout(() => {
highlightedItem = null;
}, 2000);
}
}
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
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)));
}
let grupoContent = $derived(selectedGrupo ? getItemsForGrupo(selectedGrupo) : { subgrupos: [] });
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
// Contadores
let totalItems = $derived(allItems.length);
let numSubgrupos = $derived(() => {
const pairs = new Set();
allItems.forEach(i => pairs.add(`${i.organismo_grupo}-${i.organismo_sub_grupo}`));
return pairs.size;
});
</script>
<svelte:head>
<title>Organismos | Presupuesto Público</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" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo);">
<!-- Header -->
<header class="border-b" style="border-color: var(--theme-borde);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
<nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
</a>
<div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
<a href="/" class="transition-colors hover:opacity-80">Inicio</a>
<span style="opacity: 0.5;">/</span>
<a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
</div>
</nav>
<div class="max-w-3xl">
<p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Clasificador de Organismos Financiadores
</p>
<h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
¿Quién financia el gasto?
</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Clasifica las fuentes de financiamiento según su <strong style="color: var(--theme-titulo);">origen</strong>:
recursos internos, crédito externo, donaciones, etc.
</p>
{#if totalItems > 0}
<p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> organismos en
{grupos.length} grupos y {numSubgrupos()} subgrupos
</p>
{/if}
<div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
<span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body);">
Grupo
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
Subgrupo
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
Organismo
</span>
</div>
</div>
</div>
</header>
{#if loading}
<div class="flex items-center justify-center py-20">
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
</div>
{:else}
<!-- Botón móvil -->
<div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
<button
onclick={() => sidebarOpen = !sidebarOpen}
class="flex items-center gap-2 text-sm w-full"
style="color: var(--theme-texto);"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="font-medium truncate">{selectedGrupo ? selectedGrupo.organismo_desc_grupo : 'Seleccionar grupo'}</span>
<svg class="w-4 h-4 ml-auto flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="max-w-screen-xl mx-auto flex">
<!-- Sidebar izquierda -->
{#if sidebarOpen}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/30 z-40 lg:hidden"
onclick={() => sidebarOpen = false}
></div>
{/if}
<aside class="
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
lg:translate-x-0
fixed lg:relative
inset-y-0 left-0
w-72 lg:w-56
z-50 lg:z-auto
transition-transform duration-200 ease-in-out
lg:flex-shrink-0
shadow-xl lg:shadow-none
" style="background-color: var(--theme-body);">
<div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-4">
<div class="flex justify-between items-center mb-4 lg:hidden">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos</span>
<button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Buscador -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar organismo..."
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
/>
</div>
{#if isSearching}
<div class="space-y-1">
<p class="text-xs uppercase tracking-wide mb-2 px-2" style="color: var(--theme-texto);">
{searchResults.length} resultados
</p>
{#each searchResults as result}
<button
class="w-full text-left px-2 py-2 text-sm rounded transition-all"
style="color: var(--theme-titulo);"
onclick={() => goToSearchResult(result)}
>
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.organismo}</span>
<span class="ml-1 block truncate">{result.organismo_desc_organismo}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
{/if}
</div>
{:else}
<nav>
<p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
<ul class="space-y-1">
{#each grupos as grupo}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
style="{selectedGrupo?.organismo_grupo === grupo.organismo_grupo
? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 2px solid var(--theme-accent);`
: `color: var(--theme-texto);`}"
onclick={() => selectGrupo(grupo)}
>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">{grupo.organismo_grupo}</span>
{grupo.organismo_desc_grupo}
</button>
</li>
{/each}
</ul>
</nav>
{/if}
</div>
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
<div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{#if selectedGrupo}
{#key selectedGrupo.organismo_grupo}
<div in:fade={{ duration: 200, delay: 50 }}>
<!-- Título del grupo -->
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">Grupo {selectedGrupo.organismo_grupo}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2" style="color: var(--theme-titulo);">
{selectedGrupo.organismo_desc_grupo}
</h2>
</div>
<!-- Subgrupos -->
<div class="space-y-12">
{#each grupoContent.subgrupos as subgrupo}
<section
id="sg-{subgrupo.organismo_sub_grupo}"
class="scroll-mt-4"
>
<div class="flex items-start gap-2 sm:gap-4 mb-3">
<span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{subgrupo.organismo_sub_grupo}</span>
<div class="flex-1">
<h3 class="text-base sm:text-lg font-medium" style="color: var(--theme-titulo);">
{subgrupo.organismo_desc_sub_grupo}
</h3>
</div>
</div>
<!-- Organismos -->
{#if subgrupo.organismos?.length > 0}
<div class="ml-4 sm:ml-8 lg:ml-16 space-y-3 border-l pl-4 sm:pl-6 lg:pl-8" style="border-color: var(--theme-borde);">
{#each subgrupo.organismos as org}
{@const orgDescs = parseDescripciones(org.descripciones)}
<div
id="org-{org.organismo}"
class="scroll-mt-4 py-2 transition-all duration-300 rounded-md px-2 -mx-2"
style="{highlightedItem === org.organismo ? 'background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent);' : ''}"
>
<div class="flex items-start gap-2 sm:gap-3">
<span class="font-mono text-xs pt-0.5 flex-shrink-0" style="color: var(--theme-texto);">{org.organismo}</span>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{org.organismo_desc_organismo}</span>
{#if org.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(org)}
title="Ver variaciones de descripción"
>
{org.n_variaciones} var.
</button>
{/if}
<a
href="/organismo/{org.organismo}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h4>
{#if org.organismo_sigla_organismo && org.organismo_sigla_organismo !== 'NC'}
<p class="text-xs font-mono mt-0.5" style="color: var(--theme-texto); opacity: 0.7;">{org.organismo_sigla_organismo}</p>
{/if}
{#if orgDescs.length > 0 && orgDescs[0].descripcion}
<p class="text-xs mt-1 leading-relaxed" style="color: var(--theme-texto);">{orgDescs[0].descripcion}</p>
{/if}
{#if org.gestiones}
<p class="text-xs mt-1 font-mono" style="color: var(--theme-texto); opacity: 0.6;">
Vigente: {org.gestiones}
</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
{/each}
</div>
</div>
{/key}
{/if}
</div>
</main>
<!-- Sidebar derecha: En esta página -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
{#if selectedGrupo && !isSearching}
<nav class="space-y-2">
{#each grupoContent.subgrupos as subgrupo}
<div>
<a
href="#sg-{subgrupo.organismo_sub_grupo}"
class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="{subgrupo.organismo_desc_sub_grupo}"
>
{subgrupo.organismo_desc_sub_grupo}
</a>
{#if subgrupo.organismos?.length > 0}
<div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);">
{#each subgrupo.organismos.slice(0, 5) as org}
<a
href="#org-{org.organismo}"
class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto); opacity: 0.7;"
title="{org.organismo_desc_organismo}"
>
{org.organismo_desc_organismo}
</a>
{/each}
{#if subgrupo.organismos.length > 5}
<span class="text-xs" style="color: var(--theme-texto); opacity: 0.5;">+{subgrupo.organismos.length - 5} más</span>
{/if}
</div>
{/if}
</div>
{/each}
</nav>
{/if}
</div>
</aside>
</div>
{/if}
</div>
<!-- Modal de detalle -->
{#if selectedItem}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
onclick={closeDetail}
>
<div
transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
style="background-color: var(--theme-surface);"
onclick={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<div>
<p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">Organismo</p>
<h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
<span class="font-mono" style="color: var(--theme-texto);">{selectedItem.organismo}</span>
<span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
{selectedItem.organismo_desc_organismo}
</h3>
</div>
<button
onclick={closeDetail}
class="text-2xl leading-none"
style="color: var(--theme-texto);"
>
&times;
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
{#if selectedItem.n_variaciones > 1}
Descripciones ({selectedItem.n_variaciones} variaciones)
{:else}
Descripción
{/if}
</h4>
{#if selectedItem.descripciones}
{@const descs = parseDescripciones(selectedItem.descripciones)}
{#if descs.length > 0}
<div class="space-y-4">
{#each descs as desc}
<div class="p-4 rounded-lg" style="background-color: var(--theme-fill);">
<p class="text-xs font-mono mb-2" style="color: var(--theme-accent);">{desc.rangos}</p>
<p class="text-sm leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion || 'Sin descripción'}</p>
</div>
{/each}
</div>
{:else}
<p class="text-sm" style="color: var(--theme-texto);">Sin descripciones registradas.</p>
{/if}
{:else}
<p class="text-sm" style="color: var(--theme-texto);">Sin descripciones registradas.</p>
{/if}
</div>
</div>
</div>
{/if}
......@@ -48,16 +48,20 @@
});
// Helpers para derivar jerarquía desde código rubro
// Jerarquía: Tipo (XX000) → Clase (XXX00) → Cuenta (XXXX0) → Subcuenta (XXXXX)
function getTipoFromRubro(rubro) {
return rubro.charAt(0);
// Tipo = primeros 2 dígitos (XX de XX000)
return rubro.substring(0, 2);
}
function getClaseFromRubro(rubro) {
return rubro.substring(0, 2);
// Clase = primeros 3 dígitos (XXX de XXX00)
return rubro.substring(0, 3);
}
function getCuentaFromRubro(rubro) {
return rubro.substring(0, 3);
// Cuenta = primeros 4 dígitos (XXXX de XXXX0)
return rubro.substring(0, 4);
}
function getItemsForTipo(tipoCode) {
......
......@@ -2,18 +2,40 @@ import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código finfun
// Jerarquía: Finalidad (1 dígito) → Grupo Función (2 dígitos) → Función (3+ dígitos)
// 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"
function getFinalidadCode(finfun) {
return finfun.charAt(0);
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);
}
function getGrpFuncionCode(finfun) {
return finfun.substring(0, 2);
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 getNivel(finfun) {
if (finfun.length === 1) return 'finalidad';
if (finfun.length === 2) return 'grpfuncion';
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';
}
......@@ -77,27 +99,40 @@ export async function load({ params }) {
// Buscar hijos según nivel
if (nivel === 'finalidad') {
// Hijos: grupos de función que empiecen con el mismo dígito
// Hijos: grupos de función de esta finalidad
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'grpfuncion')
.like('finfun', `${codigo}%`)
.order('finfun');
hijos = hijosData || [];
// 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;
});
}
} else if (nivel === 'grpfuncion') {
// Hijos: funciones que empiecen con los mismos 2 dígitos
// Hijos: funciones de este grupo
const grpCode = getGrpFuncionCode(codigo);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'funcion')
.like('finfun', `${codigo}%`)
.order('finfun');
hijos = hijosData || [];
// Filtrar solo los hijos directos de este grupo
if (hijosData) {
hijos = hijosData.filter(h => {
const hijoGrp = getGrpFuncionCode(h.finfun);
return hijoGrp === grpCode;
});
}
}
// funcion no tiene hijos
......
......@@ -1029,9 +1029,9 @@
<p class="context-hint">Funciones relacionadas. <span class="desktop-hint">Haz click</span><span class="mobile-hint">Toca</span> para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/finfun/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_finfun}">
<a href="/finfun/{padre.finfun}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_finfun}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.objeto}</span>
<span class="tree-code">{padre.finfun}</span>
<span class="tree-name">{padre.desc_finfun}</span>
</a>
{/each}
......@@ -1045,9 +1045,9 @@
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/finfun/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_finfun}">
<a href="/finfun/{hijo.finfun}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_finfun}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.objeto}</span>
<span class="tree-code">{hijo.finfun}</span>
<span class="tree-name">{hijo.desc_finfun}</span>
</a>
{/each}
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
console.time('[SERVER] Total load fuente');
const { codigo } = params;
console.time('[SERVER] Query clas_fuentes');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('*')
.eq('fuente', codigo);
console.timeEnd('[SERVER] Query clas_fuentes');
if (dbError || !data || data.length === 0) {
throw error(404, 'Fuente no encontrada');
}
const fuente = data[0];
// Obtener hermanos (otras fuentes del mismo grupo)
let hermanos = [];
console.time('[SERVER] Query hermanos');
const { data: hermanosData } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('*')
.eq('grupo_fuente', fuente.grupo_fuente)
.neq('fuente', codigo)
.order('fuente');
if (hermanosData) {
hermanos = hermanosData;
}
console.timeEnd('[SERVER] Query hermanos');
console.timeEnd('[SERVER] Total load fuente');
return {
fuente,
hermanos
};
}
<script>
import { onMount } from 'svelte';
// Datos del servidor (reactivos)
let { data } = $props();
let fuenteData = $derived(data.fuente);
let hermanos = $derived(data.hermanos);
// Estado
let mounted = $state(false);
onMount(() => {
setTimeout(() => { mounted = true; }, 50);
});
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
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)));
}
let descripciones = $derived(parseDescripciones(fuenteData.descripciones));
</script>
<svelte:head>
<title>{fuenteData.desc_fuente || fuenteData.fuente} | Clasificador de Fuentes</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/fuentes" 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>
<div class="dashboard-layout">
<!-- CONTEXTO IZQUIERDO -->
<aside class="dashboard-context">
<div class="context-card">
<h2 class="context-main-title">Contexto</h2>
<!-- Info del grupo -->
<div class="context-section">
<h3 class="context-subtitle">Ubicación</h3>
<div class="ubicacion-info">
<div class="ubicacion-item">
<span class="ubicacion-label">Grupo {fuenteData.grupo_fuente}</span>
<span class="ubicacion-value">{fuenteData.desc_grupo_fuente}</span>
</div>
</div>
</div>
<!-- Hermanos -->
{#if hermanos.length > 0}
<div class="context-separator"></div>
<div class="context-section">
<h3 class="context-subtitle">Mismo grupo ({hermanos.length})</h3>
<p class="context-hint">Otras fuentes del mismo grupo.</p>
<div class="hermanos-list">
{#each hermanos.slice(0, 10) as hermano}
<a href="/fuente/{hermano.fuente}" class="hermano-link">
<span class="hermano-code">{hermano.fuente}</span>
<span class="hermano-name">{hermano.desc_fuente}</span>
</a>
{/each}
{#if hermanos.length > 10}
<span class="hermanos-more">+{hermanos.length - 10} más</span>
{/if}
</div>
</div>
{/if}
</div>
</aside>
<!-- 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">{fuenteData.fuente}</span>
</div>
<h1 class="main-title-name">{fuenteData.desc_fuente}</h1>
</div>
<!-- Info cards -->
<div class="info-grid">
<div class="info-card">
<span class="info-label">Grupo</span>
<span class="info-value">{fuenteData.grupo_fuente}</span>
<span class="info-desc">{fuenteData.desc_grupo_fuente}</span>
</div>
{#if fuenteData.gestiones}
<div class="info-card">
<span class="info-label">Gestiones</span>
<span class="info-value">{fuenteData.n_gestiones || ''} años</span>
<span class="info-desc">{fuenteData.gestiones}</span>
</div>
{/if}
{#if fuenteData.n_variaciones > 1}
<div class="info-card">
<span class="info-label">Variaciones</span>
<span class="info-value">{fuenteData.n_variaciones}</span>
<span class="info-desc">Cambios en la descripción a lo largo del tiempo</span>
</div>
{/if}
</div>
<!-- Descripciones -->
{#if descripciones.length > 0}
<div class="descripciones-section">
<h2 class="descripciones-title">
{#if descripciones.length > 1}
Descripciones ({descripciones.length} variaciones)
{:else}
Descripción
{/if}
</h2>
<div class="descripciones-list">
{#each descripciones as desc, i}
<div class="descripcion-item" class:vigente={i === 0}>
<p class="descripcion-rangos">
{#if i === 0 && descripciones.length > 1}
<span class="vigente-badge">Vigente</span>
{/if}
{desc.rangos}
</p>
<p class="descripcion-texto">{desc.descripcion}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</main>
</div>
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
BASE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
padding: 1rem;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.4s ease, transform 0.4s ease;
}
.page.mounted {
opacity: 1;
transform: translateY(0);
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
max-width: 1400px;
margin: 0 auto 1.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;
}
.back-link:hover {
color: var(--text-primary, #fff);
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-context {
order: 2;
}
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
position: sticky;
top: 5rem;
height: fit-content;
}
.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;
}
.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;
}
.context-section {
margin-bottom: 1.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;
}
.context-hint {
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;
}
/* ═══════════════════════════════════════════════════════════════
UBICACION INFO
═══════════════════════════════════════════════════════════════ */
.ubicacion-info {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ubicacion-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
border-radius: 8px;
}
.ubicacion-label {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--text-tertiary, #666);
text-transform: uppercase;
}
.ubicacion-value {
font-size: 0.85rem;
color: var(--text-primary, #fff);
}
/* ═══════════════════════════════════════════════════════════════
HERMANOS LIST
═══════════════════════════════════════════════════════════════ */
.hermanos-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hermano-link {
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;
}
.hermano-link:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-primary, #fff);
}
.hermano-code {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
opacity: 0.6;
flex-shrink: 0;
min-width: 2.5rem;
}
.hermano-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hermanos-more {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
padding: 0.5rem 0.75rem;
}
/* ═══════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
min-width: 0;
}
.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;
}
.main-header {
margin-bottom: 2rem;
}
.main-title-pills {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.pill {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.pill-codigo {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-secondary, #aaa);
font-family: 'DM Mono', monospace;
}
.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;
}
/* ═══════════════════════════════════════════════════════════════
INFO GRID
═══════════════════════════════════════════════════════════════ */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.info-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
border-radius: 12px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
}
.info-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #666);
}
.info-value {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
color: var(--text-primary, #fff);
}
.info-desc {
font-size: 0.8rem;
color: var(--text-secondary, #aaa);
line-height: 1.4;
}
/* ═══════════════════════════════════════════════════════════════
DESCRIPCIONES
═══════════════════════════════════════════════════════════════ */
.descripciones-section {
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
padding-top: 2rem;
}
.descripciones-title {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #aaa);
margin-bottom: 1rem;
}
.descripciones-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.descripcion-item {
padding: 1rem;
border-left: 2px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 0 8px 8px 0;
background: var(--hover-bg, rgba(255, 255, 255, 0.02));
}
.descripcion-item.vigente {
border-left-color: var(--theme-accent, #3b82f6);
background: color-mix(in srgb, var(--theme-accent, #3b82f6) 10%, transparent);
}
.descripcion-rangos {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--text-tertiary, #666);
margin-bottom: 0.5rem;
}
.vigente-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--theme-accent, #3b82f6);
color: white;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 0.5rem;
}
.descripcion-texto {
font-size: 0.9rem;
color: var(--text-primary, #fff);
line-height: 1.6;
}
</style>
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
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 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');
console.timeEnd('[SERVER] Total load organismo');
return {
organismo,
hermanos
};
}
<script>
import { onMount } from 'svelte';
// Datos del servidor (reactivos)
let { data } = $props();
let organismoData = $derived(data.organismo);
let hermanos = $derived(data.hermanos);
// Estado
let mounted = $state(false);
onMount(() => {
setTimeout(() => { mounted = true; }, 50);
});
</script>
<svelte:head>
<title>{organismoData.organismo_desc_organismo || organismoData.organismo} | Clasificador de Organismos</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>
<div class="dashboard-layout">
<!-- CONTEXTO IZQUIERDO -->
<aside class="dashboard-context">
<div class="context-card">
<h2 class="context-main-title">Contexto</h2>
<!-- Info del grupo y subgrupo -->
<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>
</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>
</div>
</div>
</div>
<!-- Hermanos -->
{#if hermanos.length > 0}
<div class="context-separator"></div>
<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>
</a>
{/each}
{#if hermanos.length > 10}
<span class="hermanos-more">+{hermanos.length - 10} más</span>
{/if}
</div>
</div>
{/if}
</div>
</aside>
<!-- 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>
<!-- 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>
<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>
{#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>
<style>
/* ═══════════════════════════════════════════════════════════════
BASE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
padding: 1rem;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.4s ease, transform 0.4s ease;
}
.page.mounted {
opacity: 1;
transform: translateY(0);
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
max-width: 1400px;
margin: 0 auto 1.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;
}
.back-link:hover {
color: var(--text-primary, #fff);
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-context {
order: 2;
}
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
position: sticky;
top: 5rem;
height: fit-content;
}
.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;
}
.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;
}
.context-section {
margin-bottom: 1.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;
}
.context-hint {
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;
}
/* ═══════════════════════════════════════════════════════════════
UBICACION INFO
═══════════════════════════════════════════════════════════════ */
.ubicacion-info {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ubicacion-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
border-radius: 8px;
}
.ubicacion-label {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--text-tertiary, #666);
text-transform: uppercase;
}
.ubicacion-value {
font-size: 0.85rem;
color: var(--text-primary, #fff);
}
/* ═══════════════════════════════════════════════════════════════
HERMANOS LIST
═══════════════════════════════════════════════════════════════ */
.hermanos-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hermano-link {
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;
}
.hermano-link:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-primary, #fff);
}
.hermano-code {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
opacity: 0.6;
flex-shrink: 0;
min-width: 2.5rem;
}
.hermano-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hermanos-more {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
padding: 0.5rem 0.75rem;
}
/* ═══════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
min-width: 0;
}
.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;
}
.main-header {
margin-bottom: 2rem;
}
.main-title-pills {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.pill {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.pill-codigo {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-secondary, #aaa);
font-family: 'DM Mono', monospace;
}
.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;
}
.main-sigla {
font-family: 'DM Mono', monospace;
font-size: 0.9rem;
color: var(--text-tertiary, #666);
margin-top: 0.5rem;
}
/* ═══════════════════════════════════════════════════════════════
INFO GRID
═══════════════════════════════════════════════════════════════ */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.info-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
border-radius: 12px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
}
.info-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #666);
}
.info-value {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
color: var(--text-primary, #fff);
}
.info-desc {
font-size: 0.8rem;
color: var(--text-secondary, #aaa);
line-height: 1.4;
}
</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;
}
export async function load({ params }) {
const { codigo } = params;
......@@ -15,88 +46,81 @@ export async function load({ params }) {
}
const rubro = data[0];
const nivel = rubro.nivel || getNivelFromCode(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
// Buscar padre según nivel
if (rubro.nivel === 'sub_cuenta') {
// Padre es cuenta
const { data: padreData } = await supabase
// 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('*')
.eq('nivel', 'cuenta')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase)
.eq('cuenta', rubro.cuenta);
if (padreData?.length) padres.push(padreData[0]);
}
.in('rubro', [tipoCode, claseCode, cuentaCode])
.order('rubro');
if (rubro.nivel === 'cuenta' || rubro.nivel === 'sub_cuenta') {
// Padre es clase
const { data: padreData } = await supabase
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('*')
.eq('nivel', 'clase')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase);
if (padreData?.length) padres.unshift(padreData[0]);
}
.in('rubro', [tipoCode, claseCode])
.order('rubro');
if (padresData) padres = padresData;
} else if (nivel === 'clase') {
// Padre: tipo
const tipoCode = getTipoCode(codigo);
if (rubro.nivel !== 'tipo') {
// Padre es tipo
const { data: padreData } = await supabase
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'tipo')
.eq('tipo', rubro.tipo);
if (padreData?.length) padres.unshift(padreData[0]);
.eq('rubro', tipoCode);
if (padresData) padres = padresData;
}
// tipo no tiene padres
// Buscar hijos según nivel
if (rubro.nivel === 'tipo') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'clase')
.eq('tipo', rubro.tipo)
.order('rubro');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
return acc;
}, []) || [];
} else if (rubro.nivel === 'clase') {
const nextNivel = getNextNivel(nivel);
if (nextNivel) {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'cuenta')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase)
.eq('nivel', nextNivel)
.like('rubro', `${codigo.replace(/0+$/, '')}%`)
.order('rubro');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
return acc;
}, []) || [];
} else if (rubro.nivel === 'cuenta') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'sub_cuenta')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase)
.eq('cuenta', rubro.cuenta)
.order('rubro');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
return acc;
}, []) || [];
if (hijosData) {
// Filtrar solo hijos directos (siguiente nivel)
hijos = hijosData.filter(h => {
if (nivel === 'tipo') {
// Hijos de tipo son clase: XXX00 donde XX = tipo
return h.rubro.startsWith(codigo.substring(0, 2)) && h.nivel === 'clase';
} else if (nivel === 'clase') {
// Hijos de clase son cuenta: XXXX0 donde XXX = clase
return h.rubro.startsWith(codigo.substring(0, 3)) && h.nivel === 'cuenta';
} 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;
});
}
}
return {
......
// Desactivar SSR para esta página porque hyparquet y @observablehq/plot
// requieren APIs del navegador (fetch con ArrayBuffer, DOM)
export const ssr = false;
<script>
import { onMount } from 'svelte';
import { parquetReadObjects, asyncBufferFromUrl } from 'hyparquet';
import * as Plot from '@observablehq/plot';
import * as d3 from 'd3';
// Estado de datos
let timeserie = $state([]);
let ejecucionEntidades = $state([]);
let ejecucionClasificadores = $state([]);
let timeline = $state([]);
let loading = $state(true);
let error = $state(null);
// Controles interactivos
let diasSeleccionados = $state('total');
let incluirTransferencias = $state(false); // Por defecto sin transferencias (como el código R)
let busquedaEntidad = $state('');
// Clasificadores disponibles con sus labels (keys deben coincidir con el parquet)
const clasificadores = [
{ key: 'Entidades', label: 'Quién gastó más' },
{ key: 'Objetos', label: 'En qué se gastó más' },
{ key: 'Finalidad y Función', label: 'Para qué función' },
{ key: 'Actividad Económica', label: 'En qué sector económico' },
{ key: 'Ubicación', label: 'En qué municipio se gastó más' }
];
// Estado de hover por clasificador
let hoverData = $state({
'Entidades': null,
'Objetos': null,
'Finalidad y Función': null,
'Actividad Económica': null,
'Ubicación': null
});
// Contenedores SVG para cada clasificador
let barContainers = $state({});
// Contenedor y estado para entidades
let entidadesBarContainer = $state(null);
let entidadHover = $state(null);
// Control de visualización de entidades
let entidadesVisibles = $state(10);
let rangoEjecucionMin = $state(0);
let rangoEjecucionMax = $state(100);
// Drag del rango
let sliderContainer = $state(null);
let isDraggingRange = $state(false);
let dragStartX = $state(0);
let dragStartMin = $state(0);
let dragStartMax = $state(0);
function handleRangeMouseDown(e) {
isDraggingRange = true;
dragStartX = e.clientX;
dragStartMin = rangoEjecucionMin;
dragStartMax = rangoEjecucionMax;
document.addEventListener('mousemove', handleRangeMouseMove);
document.addEventListener('mouseup', handleRangeMouseUp);
}
function handleRangeMouseMove(e) {
if (!isDraggingRange || !sliderContainer) return;
const rect = sliderContainer.getBoundingClientRect();
const deltaX = e.clientX - dragStartX;
const deltaPercent = (deltaX / rect.width) * 100;
const rangeSize = dragStartMax - dragStartMin;
let newMin = dragStartMin + deltaPercent;
let newMax = dragStartMax + deltaPercent;
// Limitar al rango 0-100
if (newMin < 0) {
newMin = 0;
newMax = rangeSize;
}
if (newMax > 100) {
newMax = 100;
newMin = 100 - rangeSize;
}
rangoEjecucionMin = Math.round(newMin);
rangoEjecucionMax = Math.round(newMax);
}
function handleRangeMouseUp() {
isDraggingRange = false;
document.removeEventListener('mousemove', handleRangeMouseMove);
document.removeEventListener('mouseup', handleRangeMouseUp);
}
// Búsqueda para timeline cards
let busquedaTimeline = $state('');
// Labels dinámicos para periodos de tiempo
function formatearFechaCorta(fecha) {
return fecha.toLocaleDateString('es-BO', { day: 'numeric', month: 'short' }).replace('.', '');
}
let labelsPeriodo = $derived.by(() => {
const hoy = new Date();
const ayer = new Date(hoy);
ayer.setDate(ayer.getDate() - 1);
const hace3 = new Date(hoy);
hace3.setDate(hace3.getDate() - 3);
const hace7 = new Date(hoy);
hace7.setDate(hace7.getDate() - 7);
return [
{ value: '1', label: `Hoy, ${formatearFechaCorta(hoy)}` },
{ value: '3', label: `Últimos 3 días` },
{ value: '7', label: `Última semana` },
{ value: 'total', label: `En ${hoy.getFullYear()}` }
];
});
// Navegación interna entre secciones
let seccionActiva = $state(0);
const secciones = [
{ id: 'clasificadores', titulo: '¿En qué se gasta?' },
{ id: 'entidades', titulo: '¿Quién ejecuta más?' },
{ id: 'programas', titulo: '¿En qué programas?' },
{ id: 'evolucion', titulo: '¿Cómo evoluciona?' }
];
function irASeccion(index) {
seccionActiva = index;
const elemento = document.getElementById(secciones[index].id);
if (elemento && vistaContainer) {
elemento.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Actualizar URL hash
history.replaceState(null, '', `#${secciones[index].id}`);
}
// Contenedor para scroll-snap
let vistaContainer = $state(null);
// Detectar sección activa al hacer scroll
function detectarSeccion() {
if (!vistaContainer) return;
const scrollY = vistaContainer.scrollTop;
const vh = window.innerHeight;
let nuevaSeccion = 0;
if (scrollY < vh * 0.5) {
nuevaSeccion = 0;
} else if (scrollY < vh * 1.5) {
nuevaSeccion = 1;
} else if (scrollY < vh * 2.5) {
nuevaSeccion = 2;
} else {
nuevaSeccion = 3;
}
// Solo actualizar URL si cambió la sección
if (nuevaSeccion !== seccionActiva) {
seccionActiva = nuevaSeccion;
history.replaceState(null, '', `#${secciones[nuevaSeccion].id}`);
}
}
// Ir a sección según hash en URL al cargar
function irASeccionDesdeHash() {
const hash = window.location.hash.slice(1);
if (hash) {
const index = secciones.findIndex(s => s.id === hash);
if (index !== -1) {
setTimeout(() => {
const elemento = document.getElementById(hash);
if (elemento) {
elemento.scrollIntoView({ behavior: 'auto', block: 'start' });
seccionActiva = index;
}
}, 100);
}
}
}
// Cards expandidas (por id de entidad)
let cardsExpandidas = $state({});
// Elementos DOM para los gráficos
let chartDiariaContainer = $state(null);
let chartAcumuladaContainer = $state(null);
// Colores del tema
const colores = {
base: '#363534',
claro: '#F3EAD8',
rojo: '#DB281C',
amarillo: '#F4E410',
verde: '#007C38',
acento: '#C9A751'
};
// Función para obtener variables CSS
function getCSSVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// Colores de gráficos (se actualizan según el tema)
function getChartColors() {
return {
barDefault: getCSSVar('--chart-bar-default') || '#e0e0e0',
barHighlight: getCSSVar('--chart-bar-highlight') || '#F3EAD8',
barStroke: getCSSVar('--chart-bar-stroke') || '#b0b0b0',
barStrokeDefault: getCSSVar('--chart-bar-stroke-default') || 'white',
trackBg: getCSSVar('--chart-track-bg') || '#f5f5f5',
grid: getCSSVar('--chart-grid') || '#bbb',
label: getCSSVar('--chart-label') || '#888'
};
}
// Cargar archivo Parquet usando asyncBufferFromUrl
async function cargarParquet(url) {
const file = await asyncBufferFromUrl({ url });
const data = await parquetReadObjects({ file });
return data;
}
onMount(async () => {
// Observer para detectar cambios de tema y re-renderizar gráficos
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
// Re-renderizar gráficos cuando cambia el tema
if (!loading) {
setTimeout(() => {
clasificadores.forEach(({ key }) => {
if (barContainers[key]) {
renderizarBarras(key);
}
});
if (entidadesBarContainer) {
renderizarEntidades();
}
}, 50);
}
}
});
});
themeObserver.observe(document.documentElement, { attributes: true });
try {
// Cargar todos los parquets en paralelo
const [tsData, entData, clasData, tlData] = await Promise.all([
cargarParquet('/timeserie@4.parquet'),
cargarParquet('/ejecucion_entidades@1.parquet'),
cargarParquet('/ejecucion_clasificadores@3.parquet'),
cargarParquet('/timeline@1.parquet')
]);
// Los datos ya vienen como objetos
timeserie = tsData;
ejecucionEntidades = entData;
ejecucionClasificadores = clasData;
timeline = tlData;
loading = false;
// Ir a sección según hash en URL
irASeccionDesdeHash();
} catch (e) {
error = e.message;
loading = false;
console.error('Error cargando datos:', e);
}
return () => {
themeObserver.disconnect();
};
});
// Filtrar datos por transferencias y días
let timeserieFiltered = $derived(
timeserie.filter(d => d.transferencias === incluirTransferencias)
);
// Datos por clasificador para las barras apiladas (sin agrupar, cada hijo es un segmento)
let datosPorClasificador = $derived.by(() => {
const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados;
const result = {};
clasificadores.forEach(({ key }) => {
const filtered = ejecucionClasificadores.filter(d =>
d.transferencias === incluirTransferencias &&
String(d.dias) === String(dias) &&
d.clasificador === key
);
// Ordenar por devengado descendente (el más grande primero)
result[key] = filtered.sort((a, b) => b.devengado - a.devengado);
});
return result;
});
// Total por clasificador
let totalesPorClasificador = $derived.by(() => {
const result = {};
clasificadores.forEach(({ key }) => {
const datos = datosPorClasificador[key] || [];
result[key] = datos.reduce((sum, d) => sum + d.devengado, 0);
});
return result;
});
// Item mostrado (hover o primero)
function getDisplayItem(key) {
const hover = hoverData[key];
if (hover) return hover;
const datos = datosPorClasificador[key] || [];
return datos[0] || null;
}
// Renderizar barras D3 para un clasificador
function renderizarBarras(key) {
const container = barContainers[key];
if (!container) return;
const datos = datosPorClasificador[key] || [];
if (datos.length === 0) return;
const total = totalesPorClasificador[key] || 1;
const height = 52;
const radius = 4;
// Limpiar
d3.select(container).selectAll('*').remove();
// Obtener ancho real del contenedor
const containerRect = container.getBoundingClientRect();
const width = containerRect.width || container.clientWidth || 800;
const svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('preserveAspectRatio', 'none')
.style('display', 'block');
// Calcular posiciones acumuladas
let cumulative = 0;
const segments = datos.map(d => {
const segWidth = (d.devengado / total) * width;
const segment = {
...d,
x: cumulative,
width: segWidth
};
cumulative += segWidth;
return segment;
});
// Colores dinámicos según el tema
const chartColors = getChartColors();
const colorDefault = chartColors.barDefault;
const colorHover = chartColors.barHighlight;
const colorFirst = chartColors.barHighlight;
// Crear grupo para cada segmento
const bars = svg.selectAll('rect')
.data(segments)
.enter()
.append('rect')
.attr('x', d => d.x)
.attr('y', 2)
.attr('width', d => Math.max(d.width - 1, 1))
.attr('height', height - 4)
.attr('rx', (d, i) => {
// Bordes redondeados solo en extremos
if (i === 0) return radius;
if (i === segments.length - 1) return radius;
return 0;
})
.attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
.style('cursor', 'pointer')
.on('mouseenter', function(event, d) {
// Resetear todos los segmentos primero
bars
.attr('fill', colorDefault)
.attr('stroke', chartColors.barStrokeDefault)
.attr('stroke-width', 0.5);
// Highlight el actual
d3.select(this)
.attr('fill', colorHover)
.attr('stroke', chartColors.barStroke)
.attr('stroke-width', 1);
// Actualizar estado
hoverData[key] = d;
hoverData = { ...hoverData };
})
.on('mouseleave', function(event, d) {
// Restaurar el primero como destacado
bars
.attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
// Limpiar hover
hoverData[key] = null;
hoverData = { ...hoverData };
});
}
// Renderizar todas las barras cuando cambian los datos o controles
$effect(() => {
// Dependencias explícitas
const datos = datosPorClasificador;
const trans = incluirTransferencias;
const dias = diasSeleccionados;
if (!loading) {
setTimeout(() => {
clasificadores.forEach(({ key }) => {
if (barContainers[key]) {
renderizarBarras(key);
}
});
}, 100);
}
});
// Renderizar barras de entidades
$effect(() => {
const datos = entidadesFiltered;
const trans = incluirTransferencias;
const dias = diasSeleccionados;
if (!loading && entidadesBarContainer) {
setTimeout(() => {
renderizarEntidades();
}, 150);
}
});
// Todas las entidades filtradas (sin límite)
let entidadesTodas = $derived.by(() => {
const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados;
let filtered = ejecucionEntidades.filter(d =>
d.transferencias === incluirTransferencias &&
String(d.dias) === String(dias) &&
d.ejecucion >= rangoEjecucionMin &&
d.ejecucion <= rangoEjecucionMax
);
if (busquedaEntidad) {
const q = busquedaEntidad.toLowerCase();
filtered = filtered.filter(d =>
d.entidad_desc_entidad?.toLowerCase().includes(q) ||
d.entidad_desc_area?.toLowerCase().includes(q)
);
}
// Ordenar por ejecución descendente
return filtered.sort((a, b) => b.ejecucion - a.ejecucion);
});
// Entidades visibles (con límite)
let entidadesFiltered = $derived(entidadesTodas.slice(0, entidadesVisibles));
// Entidad mostrada (hover o primera)
let displayEntidad = $derived(entidadHover || entidadesFiltered[0] || null);
// Agrupar timeline por entidad con sus programas
let timelineByEntity = $derived.by(() => {
if (timeline.length === 0) return [];
// Agrupar por entidad
const grouped = {};
timeline.forEach(item => {
const key = item.entidad;
if (!grouped[key]) {
grouped[key] = {
entidad: item.entidad,
entidad_desc_area: item.entidad_desc_area,
entidad_desc_entidad: item.entidad_desc_entidad,
total: 0,
programas: []
};
}
grouped[key].total += item.devengado;
grouped[key].programas.push({
programa: item.desc_programa,
actividad: item.desc_actividad_proyecto,
devengado: item.devengado
});
});
// Convertir a array y ordenar por total
let result = Object.values(grouped)
.map(g => ({
...g,
programas: g.programas.sort((a, b) => b.devengado - a.devengado)
}))
.sort((a, b) => b.total - a.total);
// Filtrar por búsqueda si hay texto
if (busquedaTimeline.trim()) {
const q = busquedaTimeline.toLowerCase();
result = result.filter(e =>
e.entidad_desc_entidad?.toLowerCase().includes(q) ||
e.entidad_desc_area?.toLowerCase().includes(q)
);
}
return result.slice(0, 10);
});
// Renderizar barras de entidades con D3
function renderizarEntidades() {
const container = entidadesBarContainer;
if (!container) return;
const datos = entidadesFiltered;
if (datos.length === 0) return;
const barHeight = 32;
const gap = 2;
const headerHeight = 24;
const barsHeight = datos.length * (barHeight + gap);
const height = headerHeight + barsHeight;
// Limpiar
d3.select(container).selectAll('*').remove();
// Obtener ancho real del contenedor
const containerRect = container.getBoundingClientRect();
const width = containerRect.width || container.clientWidth || 800;
const svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('preserveAspectRatio', 'xMinYMin meet')
.style('display', 'block');
// Calcular rango dinámico basado en los datos
const ejecuciones = datos.map(d => d.ejecucion);
const minEjec = Math.min(...ejecuciones);
const maxEjec = Math.max(...ejecuciones);
// Redondear el mínimo hacia abajo y máximo hacia arriba (múltiplos de 5)
const domainMin = Math.max(0, Math.floor(minEjec / 5) * 5 - 5);
const domainMax = Math.min(100, Math.ceil(maxEjec / 5) * 5 + 5);
// Escala dinámica
const xScale = d3.scaleLinear()
.domain([domainMin, domainMax])
.range([0, width]);
// Generar porcentajes para el rango dinámico (cada 10% dentro del rango)
const step = Math.max(5, Math.round((domainMax - domainMin) / 8 / 5) * 5);
const porcentajes = [];
for (let p = domainMin; p <= domainMax; p += step) {
porcentajes.push(p);
}
if (porcentajes[porcentajes.length - 1] !== domainMax) {
porcentajes.push(domainMax);
}
// Colores dinámicos según el tema
const chartColors = getChartColors();
const colorDefault = chartColors.barDefault;
const colorHighlight = chartColors.barHighlight;
// Labels arriba (se dibujan primero)
svg.selectAll('text.pct-label')
.data(porcentajes)
.enter()
.append('text')
.attr('class', 'pct-label')
.attr('x', d => xScale(d))
.attr('y', 14)
.attr('text-anchor', (d, i) => i === 0 ? 'start' : i === porcentajes.length - 1 ? 'end' : 'middle')
.attr('font-size', '11px')
.attr('font-family', 'Instrument Sans, sans-serif')
.attr('fill', chartColors.label)
.text(d => `${d}%`);
// Grupo para las barras (offset por el header)
const barsGroup = svg.append('g')
.attr('transform', `translate(0, ${headerHeight})`);
// Crear barras
const bars = barsGroup.selectAll('g.entidad-bar')
.data(datos)
.enter()
.append('g')
.attr('class', 'entidad-bar')
.attr('transform', (d, i) => `translate(0, ${i * (barHeight + gap)})`);
// Barra de fondo (100%)
bars.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', barHeight)
.attr('fill', chartColors.trackBg)
.attr('rx', 2);
// Barra de ejecución (ancho proporcional al rango dinámico)
bars.append('rect')
.attr('class', 'ejecucion-bar')
.attr('x', 0)
.attr('y', 0)
.attr('width', d => {
const clamped = Math.max(domainMin, Math.min(d.ejecucion, domainMax));
return xScale(clamped);
})
.attr('height', barHeight)
.attr('fill', (d, i) => i === 0 ? colorHighlight : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : 'none')
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0)
.attr('rx', 2)
.style('cursor', 'pointer');
// Área invisible para hover (cubre toda la barra)
bars.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', barHeight)
.attr('fill', 'transparent')
.style('cursor', 'pointer')
.on('mouseenter', function(event, d) {
// Quitar highlight de todas
barsGroup.selectAll('.ejecucion-bar')
.attr('fill', colorDefault)
.attr('stroke', 'none');
// Highlight la seleccionada con ring
d3.select(this.parentNode).select('.ejecucion-bar')
.attr('fill', colorHighlight)
.attr('stroke', chartColors.barStroke)
.attr('stroke-width', 1);
// Actualizar estado
entidadHover = d;
})
.on('mouseleave', function() {
// Restaurar primera como destacada con ring
barsGroup.selectAll('.ejecucion-bar')
.attr('fill', (d, i) => i === 0 ? colorHighlight : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : 'none')
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0);
entidadHover = null;
});
// Grid lines verticales punteadas finísimas (dibujadas al final para que estén encima)
svg.selectAll('line.grid-line')
.data(porcentajes.slice(1, -1)) // excluir 0 y 100
.enter()
.append('line')
.attr('class', 'grid-line')
.attr('x1', d => xScale(d))
.attr('x2', d => xScale(d))
.attr('y1', headerHeight)
.attr('y2', height)
.attr('stroke', chartColors.grid)
.attr('stroke-width', 0.5)
.attr('stroke-dasharray', '1,4')
.attr('stroke-linecap', 'round')
.attr('opacity', 0.7)
.style('pointer-events', 'none'); // para que no interfiera con hover
}
// Formatear números en texto literal
function formatearMonto(valor) {
if (valor >= 1e9) {
const num = (valor / 1e9).toFixed(1).replace('.0', '');
return `Bs ${num} mil millones`;
}
if (valor >= 1e6) {
const num = (valor / 1e6).toFixed(1).replace('.0', '');
return `Bs ${num} millones`;
}
if (valor >= 1e3) {
const num = (valor / 1e3).toFixed(1).replace('.0', '');
return `Bs ${num} mil`;
}
return `Bs ${valor.toFixed(0)}`;
}
// Formatear número completo con separadores de miles
function formatearNumeroCompleto(valor) {
return new Intl.NumberFormat('es-BO', {
maximumFractionDigits: 0
}).format(valor);
}
function formatearPorcentaje(valor) {
return `${valor.toFixed(1)}%`;
}
// Renderizar sparklines minimalistas
function renderizarTimeseries() {
if (timeserieFiltered.length === 0) return;
const data = timeserieFiltered
.map(d => ({
...d,
fecha: new Date(d.fecha),
diferencia: d.diferencia || 0
}))
.filter(d => !isNaN(d.fecha.getTime()))
.sort((a, b) => a.fecha - b.fecha);
// Colores dinámicos para el gráfico según tema
const isDarkTheme = document.documentElement.classList.contains('dark');
const colorTexto = isDarkTheme ? '#B8B5AD' : '#666666';
const colorGrid = isDarkTheme ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const colorRule = isDarkTheme ? 'rgba(255,255,255,0.2)' : '#ccc';
const chartConfig = {
height: 200,
marginTop: 20,
marginBottom: 30,
marginLeft: 50,
marginRight: 20,
style: {
background: 'transparent',
color: colorTexto
},
x: {
label: null,
tickFormat: d => d.toLocaleDateString('es-BO', { day: 'numeric', month: 'short' }),
ticks: 5,
tickSize: 0,
line: false
},
y: {
label: null,
tickFormat: d => {
if (d >= 1e9) return `${(d / 1e9).toFixed(0)}MM`;
if (d >= 1e6) return `${(d / 1e6).toFixed(0)}M`;
return d;
},
grid: true,
gridColor: colorGrid,
tickSize: 0,
line: false
}
};
// Calcular fecha de corte según el periodo seleccionado
// Usar la última fecha de los datos como referencia, no la fecha de hoy
const ultimaFecha = data.length > 0 ? data[data.length - 1].fecha : new Date();
let fechaCorte = null;
if (diasSeleccionados !== 'total') {
fechaCorte = new Date(ultimaFecha);
fechaCorte.setDate(fechaCorte.getDate() - parseInt(diasSeleccionados));
}
// Separar datos en "activos" (periodo seleccionado) y "fuera de foco"
const dataActiva = fechaCorte
? data.filter(d => d.fecha >= fechaCorte)
: data;
const dataFuera = fechaCorte
? data.filter(d => d.fecha < fechaCorte)
: [];
// Colores para los gráficos (según tema)
const isDark = document.documentElement.classList.contains('dark');
const colorFuera = isDark ? '#555550' : '#d0d0d0';
const colorFueraFill = isDark ? '#3a3a38' : '#e8e8e8';
// Color de fill activo = mismo que las barras highlight
const colorActivoFill = isDark ? '#C9A751' : '#F3EAD8';
const colorActivoLinea = isDark ? '#888880' : '#999999';
// Gráfico 1: Ejecución diaria (diferencia)
if (chartDiariaContainer && data.length > 0) {
chartDiariaContainer.innerHTML = '';
const widthDiaria = chartDiariaContainer.clientWidth || 400;
const marks = [
Plot.ruleY([0], { stroke: colorRule, strokeWidth: 1 })
];
// Primero la parte fuera de foco (si hay)
if (dataFuera.length > 0) {
marks.push(
Plot.areaY(dataFuera, {
x: 'fecha',
y: 'diferencia',
fill: colorFueraFill,
fillOpacity: 0.5,
curve: 'monotone-x'
}),
Plot.lineY(dataFuera, {
x: 'fecha',
y: 'diferencia',
stroke: colorFuera,
strokeWidth: 1.5,
curve: 'monotone-x'
})
);
}
// Luego la parte activa
if (dataActiva.length > 0) {
marks.push(
Plot.areaY(dataActiva, {
x: 'fecha',
y: 'diferencia',
fill: colorActivoFill,
fillOpacity: 0.6,
curve: 'monotone-x'
}),
Plot.lineY(dataActiva, {
x: 'fecha',
y: 'diferencia',
stroke: colorActivoLinea,
strokeWidth: 1.5,
curve: 'monotone-x'
})
);
}
const plotDiaria = Plot.plot({
...chartConfig,
width: widthDiaria,
marks
});
chartDiariaContainer.appendChild(plotDiaria);
}
// Gráfico 2: Ejecución acumulada (devengado)
if (chartAcumuladaContainer && data.length > 0) {
chartAcumuladaContainer.innerHTML = '';
// Crear punto de inicio en 1 de enero del año de los datos
const primerDato = data[0];
const anioData = primerDato.fecha.getFullYear();
const inicioAnio = new Date(anioData, 0, 1); // 1 de enero
// Línea desde inicio del año (valor 0) hasta el primer dato
const lineaInicio = [
{ fecha: inicioAnio, devengado: 0 },
{ fecha: primerDato.fecha, devengado: primerDato.devengado }
];
const marksAcum = [
Plot.ruleY([0], { stroke: colorRule, strokeWidth: 1 }),
// Área y línea recta desde 1 de enero hasta primer dato
Plot.areaY(lineaInicio, {
x: 'fecha',
y: 'devengado',
fill: colorActivoFill,
fillOpacity: 0.6
}),
Plot.line(lineaInicio, {
x: 'fecha',
y: 'devengado',
stroke: colorActivoLinea,
strokeWidth: 1.5
})
];
// Primero la parte fuera de foco (si hay)
if (dataFuera.length > 0) {
marksAcum.push(
Plot.areaY(dataFuera, {
x: 'fecha',
y: 'devengado',
fill: colorFueraFill,
fillOpacity: 0.5,
curve: 'monotone-x'
}),
Plot.lineY(dataFuera, {
x: 'fecha',
y: 'devengado',
stroke: colorFuera,
strokeWidth: 1.5,
curve: 'monotone-x'
})
);
}
// Luego la parte activa
if (dataActiva.length > 0) {
marksAcum.push(
Plot.areaY(dataActiva, {
x: 'fecha',
y: 'devengado',
fill: colorActivoFill,
fillOpacity: 0.6,
curve: 'monotone-x'
}),
Plot.lineY(dataActiva, {
x: 'fecha',
y: 'devengado',
stroke: colorActivoLinea,
strokeWidth: 1.5,
curve: 'monotone-x'
})
);
}
const plotAcumulada = Plot.plot({
...chartConfig,
width: chartAcumuladaContainer.clientWidth || 400,
marks: marksAcum
});
chartAcumuladaContainer.appendChild(plotAcumulada);
}
}
// Reaccionar a cambios en controles y cuando los contenedores estén listos
$effect(() => {
// Dependencias explícitas
const containers = [chartDiariaContainer, chartAcumuladaContainer];
const filters = [diasSeleccionados, incluirTransferencias];
const dataReady = timeserieFiltered.length > 0;
if (!loading && chartDiariaContainer && chartAcumuladaContainer && dataReady) {
setTimeout(() => {
renderizarTimeseries();
}, 100);
}
});
// Calcular totales
let totalDevengado = $derived.by(() => {
if (timeserieFiltered.length === 0) return 0;
return timeserieFiltered[timeserieFiltered.length - 1]?.devengado || 0;
});
let diferenciaDiaria = $derived.by(() => {
if (timeserieFiltered.length === 0) return 0;
return timeserieFiltered[timeserieFiltered.length - 1]?.diferencia || 0;
});
</script>
<svelte:head>
<title>Vista Diaria | Presupuesto Público</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="vista-diaria" style="font-family: var(--font-sans);" bind:this={vistaContainer} onscroll={detectarSeccion}>
<!-- Navegación interna fija -->
<nav class="nav-interna">
{#each secciones as seccion, i}
<button
class="nav-item"
class:active={seccionActiva === i}
onclick={() => irASeccion(i)}
>
<span class="nav-dot"></span>
<span class="nav-label">{seccion.titulo}</span>
</button>
{/each}
</nav>
{#if loading}
<div class="loading">
<p>Cargando datos...</p>
</div>
{:else if error}
<div class="error">
<p>Error: {error}</p>
</div>
{:else}
<!-- SECCIÓN 1: Clasificadores -->
<section class="vista-seccion" id="clasificadores">
<div class="vista-contenido">
<div class="contenido-centrado">
<!-- Título de sección -->
<header class="seccion-header">
<h1 class="seccion-titulo-grande">Gasto del Estado: quiénes, qué, dónde y cuánto</h1>
</header>
<!-- Contenido principal: barras clasificadores -->
<div class="clasificadores-grid">
{#each clasificadores as { key, label }}
{@const datos = datosPorClasificador[key] || []}
{@const displayItem = getDisplayItem(key)}
{#if datos.length > 0}
<div class="clasificador-row">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearNumeroCompleto(displayItem?.devengado || 0)}</span>
<span class="bs-en">Bs. en</span>
<span class="clasificador-hijo">{displayItem?.hijo || ''}</span>
<span class="clasificador-padre-annotation">({displayItem?.padre || ''})</span>
</div>
<span class="clasificador-tipo">{label}</span>
</div>
<div class="barra-d3-container" bind:this={barContainers[key]}></div>
</div>
{/if}
{/each}
</div>
<!-- Controles centrados debajo -->
<div class="controles-footer">
{#each labelsPeriodo as { value, label }}
<button
class="periodo-btn"
class:active={diasSeleccionados === value}
onclick={() => diasSeleccionados = value}
>
{label}
</button>
{/each}
<label class="toggle-transferencias">
<input type="checkbox" bind:checked={incluirTransferencias} />
<span class="toggle-label">Incluir transferencias</span>
<span class="tooltip-trigger">
<svg class="tooltip-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span class="tooltip-content">
Las transferencias son recursos que el gobierno central envía a gobiernos territoriales o al sector privado. Al desactivar esta opción se evita la doble contabilización, mostrando el gasto consolidado.
</span>
</span>
</label>
</div>
</div>
</div>
</section>
<!-- SECCIÓN 2: Entidades -->
<section class="vista-seccion" id="entidades">
<div class="vista-contenido">
<div class="contenido-centrado">
<!-- Título de sección -->
<header class="seccion-header">
<h1 class="seccion-titulo-grande">¿Quién ejecuta más?</h1>
</header>
<!-- Info de entidad seleccionada -->
{#if displayEntidad}
<div class="entidad-info">
<span class="entidad-pct">{formatearPorcentaje(displayEntidad.ejecucion)}</span>
<span class="entidad-nombre">{displayEntidad.entidad_desc_entidad}</span>
<span class="entidad-area">{displayEntidad.entidad_desc_area}</span>
</div>
{/if}
<!-- Barras de ejecución -->
<div class="entidades-bar-container" bind:this={entidadesBarContainer}></div>
<!-- Paginación -->
<div class="entidades-paginacion">
<p class="entidades-nota">
Mostrando {entidadesFiltered.length} de {entidadesTodas.length} entidades
</p>
<div class="entidades-botones">
{#if entidadesVisibles > 10}
<button class="entidades-ver-mas" onclick={() => { entidadesVisibles = Math.max(10, entidadesVisibles - 10); }}>
Ver menos
</button>
{/if}
{#if entidadesFiltered.length < entidadesTodas.length}
<button class="entidades-ver-mas" onclick={() => { entidadesVisibles += 10; }}>
Ver {Math.min(10, entidadesTodas.length - entidadesFiltered.length)} más
</button>
{/if}
</div>
</div>
<!-- Controles centrados debajo (igual que sección 1) -->
<div class="controles-footer">
{#each labelsPeriodo as { value, label }}
<button
class="periodo-btn"
class:active={diasSeleccionados === value}
onclick={() => diasSeleccionados = value}
>
{label}
</button>
{/each}
<label class="toggle-transferencias">
<input type="checkbox" bind:checked={incluirTransferencias} />
<span class="toggle-label">Incluir transferencias</span>
<span class="tooltip-trigger">
<svg class="tooltip-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span class="tooltip-content">
Las transferencias son recursos que el gobierno central envía a gobiernos territoriales o al sector privado. Al desactivar esta opción se evita la doble contabilización, mostrando el gasto consolidado.
</span>
</span>
</label>
<div class="separador-control"></div>
<!-- Filtro de rango -->
<div class="rango-control-inline">
<label class="rango-label">Rango: {rangoEjecucionMin}% – {rangoEjecucionMax}%</label>
<div class="dual-slider dual-slider-inline" bind:this={sliderContainer}>
<div class="slider-track"></div>
<div
class="slider-range"
style="left: {rangoEjecucionMin}%; right: {100 - rangoEjecucionMax}%;"
onmousedown={handleRangeMouseDown}
role="slider"
tabindex="0"
></div>
<input
type="range"
min="0"
max="100"
bind:value={rangoEjecucionMin}
class="slider-thumb slider-thumb-min"
oninput={() => {
if (rangoEjecucionMin > rangoEjecucionMax - 5) {
rangoEjecucionMin = rangoEjecucionMax - 5;
}
}}
/>
<input
type="range"
min="0"
max="100"
bind:value={rangoEjecucionMax}
class="slider-thumb slider-thumb-max"
oninput={() => {
if (rangoEjecucionMax < rangoEjecucionMin + 5) {
rangoEjecucionMax = rangoEjecucionMin + 5;
}
}}
/>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- SECCIÓN 3: Programas -->
<section class="vista-seccion" id="programas">
<div class="vista-contenido">
<div class="contenido-centrado">
<!-- Título de sección -->
<header class="seccion-header seccion-header-centrado">
<h1 class="seccion-titulo-grande">¿En qué programas?</h1>
<p class="seccion-descripcion">Detalle de programas y actividades ejecutadas en la última semana</p>
</header>
<div class="timeline-search">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
<input
type="text"
placeholder="Buscar entidad..."
bind:value={busquedaTimeline}
class="timeline-search-input"
/>
</div>
<div class="timeline-cards">
{#each timelineByEntity as entidad}
<div class="timeline-card">
<div class="timeline-card-header">
<div class="timeline-card-entity">
<span class="timeline-card-area">{entidad.entidad_desc_area}</span>
<span class="timeline-card-nombre">{entidad.entidad_desc_entidad}</span>
</div>
<div class="timeline-card-total">
<span class="timeline-card-total-label">Ejecutado</span>
<span class="timeline-card-total-monto">Bs. {formatearNumeroCompleto(entidad.total)}</span>
</div>
</div>
<div class="timeline-card-body">
{#each cardsExpandidas[entidad.entidad] ? entidad.programas : entidad.programas.slice(0, 8) as prog}
<div class="timeline-prog-row">
<div class="timeline-prog-info">
<span class="timeline-prog-programa">{prog.programa}</span>
<span class="timeline-prog-actividad">{prog.actividad}</span>
</div>
<span class="timeline-prog-monto">Bs. {formatearNumeroCompleto(prog.devengado)}</span>
</div>
{/each}
{#if entidad.programas.length > 8}
<button
class="timeline-ver-mas"
onclick={() => {
cardsExpandidas[entidad.entidad] = !cardsExpandidas[entidad.entidad];
cardsExpandidas = { ...cardsExpandidas };
}}
>
{cardsExpandidas[entidad.entidad] ? 'Ver menos' : `Ver ${entidad.programas.length - 8} más...`}
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
</section>
<!-- SECCIÓN 4: Evolución temporal -->
<section class="vista-seccion" id="evolucion">
<div class="vista-contenido">
<div class="contenido-centrado">
<!-- Título de sección -->
<header class="seccion-header">
<h1 class="seccion-titulo-grande">¿Cómo evoluciona el gasto?</h1>
</header>
<!-- Gráficos lado a lado -->
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<span class="chart-label">Ejecución diaria</span>
<span class="chart-value">{formatearMonto(diferenciaDiaria)}</span>
</div>
<div class="chart-container" bind:this={chartDiariaContainer}></div>
</div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-label">Ejecución acumulada</span>
<span class="chart-value">{formatearMonto(totalDevengado)}</span>
</div>
<div class="chart-container" bind:this={chartAcumuladaContainer}></div>
</div>
</div>
<!-- Controles centrados debajo -->
<div class="controles-footer">
{#each labelsPeriodo as { value, label }}
<button
class="periodo-btn"
class:active={diasSeleccionados === value}
onclick={() => diasSeleccionados = value}
>
{label}
</button>
{/each}
<label class="toggle-transferencias">
<input type="checkbox" bind:checked={incluirTransferencias} />
<span class="toggle-label">Incluir transferencias</span>
<span class="tooltip-trigger">
<svg class="tooltip-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span class="tooltip-content">
Las transferencias son recursos que el gobierno central envía a gobiernos territoriales o al sector privado. Al desactivar esta opción se evita la doble contabilización, mostrando el gasto consolidado.
</span>
</span>
</label>
</div>
</div>
</div>
</section>
{/if}
</div>
<style>
/* Variables de color para gráficos - Modo claro */
:global(:root) {
--chart-bar-default: #e0e0e0;
--chart-bar-highlight: #F3EAD8;
--chart-bar-stroke: #b0b0b0;
--chart-bar-stroke-default: white;
--chart-track-bg: #f5f5f5;
--chart-grid: #bbb;
--chart-label: #888;
--chart-card-header: #f5f0e6;
}
/* Variables de color para gráficos - Modo oscuro */
:global(.dark) {
--chart-bar-default: #3a3a38;
--chart-bar-highlight: #C9A751;
--chart-bar-stroke: #5a5a55;
--chart-bar-stroke-default: #2a2a28;
--chart-track-bg: #2a2a28;
--chart-grid: #4a4a45;
--chart-label: #8a8a85;
--chart-card-header: #3a3835;
}
.vista-diaria {
background: var(--theme-body);
color: var(--theme-titulo);
scroll-snap-type: y mandatory;
overflow-y: scroll;
height: 100vh;
height: 100dvh;
}
/* Loading/Error */
.loading, .error {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
}
.error {
color: #DB281C;
}
/* Navegación interna fija */
.nav-interna {
position: fixed;
right: 1.5rem;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1rem;
z-index: 100;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
flex-direction: row-reverse;
}
.nav-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
:global(.dark) .nav-dot {
background: rgba(255, 255, 255, 0.2);
}
.nav-item.active .nav-dot {
background: var(--theme-accent);
transform: scale(1.2);
}
.nav-label {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s;
white-space: nowrap;
}
.nav-item:hover .nav-label,
.nav-item.active .nav-label {
opacity: 1;
}
/* Secciones de 100vh - el navbar tiene ~56px de alto */
.vista-seccion {
min-height: 100vh;
min-height: 100dvh;
padding-top: 56px;
box-sizing: border-box;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--theme-borde);
scroll-snap-align: start;
scroll-snap-stop: always;
}
.vista-contenido {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.contenido-centrado {
width: 100%;
}
/* Controles centrados debajo del gráfico */
.controles-footer {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--theme-borde);
}
.periodo-btn {
padding: 0.5rem 1rem;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.8rem;
font-weight: 500;
border: 1px solid var(--theme-borde);
border-radius: 20px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.2s ease;
}
.periodo-btn:hover {
background: var(--theme-fill);
border-color: var(--theme-accent);
color: var(--theme-titulo);
}
.periodo-btn.active {
background: var(--theme-titulo);
border-color: var(--theme-titulo);
color: var(--theme-body);
}
/* Dark mode: botón activo con acento dorado */
:global(.dark) .periodo-btn.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: var(--theme-body);
}
.toggle-transferencias {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
margin-left: 0.5rem;
padding-left: 1rem;
border-left: 1px solid var(--theme-borde);
}
.toggle-transferencias input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--theme-accent);
cursor: pointer;
}
.toggle-label {
font-size: 0.75rem;
color: var(--theme-texto);
}
.separador-control {
width: 1px;
height: 24px;
background: var(--theme-borde);
margin: 0 0.5rem;
}
.rango-control-inline {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.rango-control-inline .rango-label {
font-size: 0.7rem;
}
.dual-slider-inline {
width: 100%;
}
/* Tooltip */
.tooltip-trigger {
position: relative;
display: inline-flex;
align-items: center;
cursor: help;
}
.tooltip-icon {
width: 14px;
height: 14px;
color: var(--theme-texto);
opacity: 0.5;
transition: opacity 0.2s;
}
.tooltip-trigger:hover .tooltip-icon {
opacity: 1;
color: var(--theme-accent);
}
.tooltip-content {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: 280px;
padding: 0.75rem 1rem;
background: var(--theme-titulo);
color: var(--theme-body);
font-size: 0.75rem;
line-height: 1.5;
border-radius: 8px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 100;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.tooltip-content::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--theme-titulo);
}
.tooltip-trigger:hover .tooltip-content {
opacity: 1;
visibility: visible;
}
/* Header de sección */
.seccion-header {
margin-bottom: 2rem;
}
.seccion-header-centrado {
text-align: center;
}
.seccion-titulo-grande {
font-family: 'Qanelas', var(--font-sans);
font-size: 2rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--theme-titulo);
}
.seccion-descripcion {
font-size: 0.875rem;
color: var(--theme-texto);
margin: 0;
}
/* Clasificadores Grid */
.clasificadores-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.clasificador-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.clasificador-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.clasificador-info {
display: flex;
align-items: baseline;
gap: 0.3rem;
flex-wrap: wrap;
}
.clasificador-monto {
font-family: var(--font-sans);
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
font-weight: 600;
color: var(--theme-titulo);
}
.bs-en {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
}
.clasificador-hijo {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-titulo);
}
.clasificador-padre-annotation {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.5;
}
.barra-d3-container {
width: 100%;
height: 52px;
border-radius: 4px;
overflow: hidden;
}
.clasificador-tipo {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.7;
flex-shrink: 0;
}
/* Timeline Section - Cards */
.timeline-search {
margin: 0 auto 1rem auto;
position: relative;
width: 100%;
max-width: 400px;
}
.search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--theme-texto);
opacity: 0.5;
pointer-events: none;
}
.timeline-search-input {
width: 100%;
padding: 0.6rem 1rem 0.6rem 2.25rem;
font-size: 0.8rem;
font-family: inherit;
border: 1px solid var(--theme-borde);
border-radius: 20px;
background: var(--theme-body);
color: var(--theme-titulo);
outline: none;
transition: border-color 0.2s;
}
.timeline-search-input:focus {
border-color: var(--theme-accent);
}
.timeline-search-input:focus + .search-icon,
.timeline-search:focus-within .search-icon {
opacity: 1;
color: var(--theme-accent);
}
.timeline-search-input::placeholder {
color: var(--theme-texto);
opacity: 0.5;
}
.timeline-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 80%;
margin: 0 auto;
}
.timeline-card {
background: var(--theme-surface);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
overflow: hidden;
}
:global(.dark) .timeline-card {
background: var(--theme-fill);
border-color: rgba(255, 255, 255, 0.1);
}
.timeline-card-header {
background: var(--chart-card-header);
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.timeline-card-entity {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.timeline-card-area {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--theme-texto);
opacity: 0.6;
}
.timeline-card-nombre {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
line-height: 1.2;
}
.timeline-card-total {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.1rem;
flex-shrink: 0;
}
.timeline-card-total-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--theme-texto);
opacity: 0.6;
}
.timeline-card-total-monto {
font-family: var(--font-sans);
font-variant-numeric: tabular-nums;
font-size: 1rem;
font-weight: 500;
color: var(--theme-titulo);
}
.timeline-card-body {
padding: 0.5rem 1rem;
text-align: center;
}
.timeline-prog-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 0.4rem 0;
border-bottom: 1px dotted rgba(0, 0, 0, 0.2);
text-align: left;
}
:global(.dark) .timeline-prog-row {
border-bottom-color: rgba(255, 255, 255, 0.2);
}
.timeline-prog-row:last-child {
border-bottom: none;
}
.timeline-prog-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.timeline-prog-programa {
font-size: 0.6rem;
color: var(--theme-texto);
opacity: 0.5;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timeline-prog-actividad {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-titulo);
line-height: 1.25;
}
.timeline-prog-monto {
font-family: var(--font-sans);
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--theme-titulo);
flex-shrink: 0;
}
.timeline-ver-mas {
display: inline;
padding: 0;
margin-top: 0.5rem;
background: transparent;
border: none;
border-bottom: 1px dotted var(--theme-texto);
color: var(--theme-texto);
font-size: 0.7rem;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
}
.timeline-ver-mas:hover {
color: var(--theme-accent);
border-bottom-color: var(--theme-accent);
}
/* Entidades Section */
.seccion-subtitulo {
font-size: 0.875rem;
color: var(--theme-texto);
margin: -1rem 0 1.5rem;
}
.entidad-info {
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.entidad-pct {
font-family: 'Qanelas', var(--font-sans);
font-size: 2.5rem;
font-weight: 600;
color: var(--theme-titulo);
line-height: 1;
}
.entidad-nombre {
font-size: 1rem;
font-weight: 500;
color: var(--theme-titulo);
line-height: 1.2;
margin-top: 0.25rem;
}
.entidad-area {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.5;
text-transform: uppercase;
letter-spacing: 0.02em;
line-height: 1.2;
}
.entidades-bar-container {
width: 100%;
overflow: hidden;
border-radius: 4px;
}
.entidades-filtros {
margin-bottom: 1.5rem;
}
.rango-control {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 400px;
}
.rango-label {
font-size: 0.75rem;
color: var(--theme-texto);
font-variant-numeric: tabular-nums;
}
.dual-slider {
position: relative;
height: 18px;
width: 100%;
}
.slider-track {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
height: 3px;
background: var(--theme-borde);
border-radius: 2px;
}
.slider-range {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 3px;
background: var(--theme-titulo);
border-radius: 2px;
cursor: grab;
z-index: 1;
}
.slider-range:active {
cursor: grabbing;
}
.slider-thumb {
position: absolute;
top: 0;
width: 100%;
height: 100%;
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
}
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: var(--theme-titulo);
border: none;
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
transition: transform 0.15s;
}
.slider-thumb::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.slider-thumb::-moz-range-thumb {
width: 12px;
height: 12px;
background: var(--theme-titulo);
border: none;
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.entidades-paginacion {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.entidades-botones {
display: flex;
gap: 0.5rem;
}
.entidades-nota {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
}
.entidades-ver-mas {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: var(--theme-texto);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
}
.entidades-ver-mas:hover {
background: var(--theme-fill);
border-color: var(--theme-accent);
color: var(--theme-titulo);
}
/* Charts Grid - Sección 4 */
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 1rem;
}
.chart-card {
background: var(--theme-fill);
border: 1px solid var(--theme-borde);
border-radius: 8px;
padding: 1.25rem;
}
:global(.dark) .chart-card {
background: var(--theme-surface);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 1rem;
}
.chart-label {
font-size: 0.8rem;
color: var(--theme-texto);
}
.chart-value {
font-family: 'Qanelas', var(--font-sans);
font-size: 1.25rem;
font-weight: 600;
color: var(--theme-titulo);
}
.chart-container {
width: 100%;
height: 220px;
}
/* Tipografía y colores para los gráficos Observable Plot */
.chart-container :global(svg) {
font-family: 'Qanelas', var(--font-sans), system-ui, sans-serif;
}
.chart-container :global(svg text) {
font-family: 'Qanelas', var(--font-sans), system-ui, sans-serif;
fill: var(--theme-texto);
}
.chart-container :global(svg [aria-label="y-grid"] line) {
stroke: rgba(0, 0, 0, 0.1);
}
:global(.dark) .chart-container :global(svg [aria-label="y-grid"] line) {
stroke: rgba(255, 255, 255, 0.1);
}
.chart-container :global(svg [aria-label="x-axis"] line),
.chart-container :global(svg [aria-label="y-axis"] line) {
stroke: var(--theme-borde);
}
/* Responsive */
@media (max-width: 900px) {
.nav-interna {
right: 0.75rem;
}
.nav-label {
display: none;
}
}
@media (max-width: 768px) {
.vista-diaria {
scroll-snap-type: y proximity;
}
.vista-seccion {
scroll-snap-stop: normal;
}
.vista-contenido {
padding: 1rem;
}
.seccion-titulo-grande {
font-size: 1.5rem;
}
.clasificador-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.clasificador-tipo {
order: -1;
}
.periodo-btn {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
}
.entidad-pct {
font-size: 2rem;
}
.charts-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.chart-container {
height: 140px;
}
.timeline-cards {
max-width: 100%;
}
}
</style>
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type