Rafael Lopez

treemap

......@@ -106,6 +106,43 @@
}
/* ============================================
SISTEMA DE TEMAS (LIGHT/DARK MODE)
Para cambiar la paleta, solo modifica estos valores
============================================ */
:root {
/* Modo claro (default) - Slate/White */
--theme-body: #fafafa;
--theme-background: #f8fafc;
--theme-titulo: #1a1a1a;
--theme-texto: #64748b;
--theme-fill: #f1f5f9;
--theme-borde: rgba(0, 0, 0, 0.08);
--theme-accent: #c9a751;
--theme-accent-hover: #b8963f;
/* Superficies alternativas */
--theme-surface: #ffffff;
--theme-surface-hover: #f8fafc;
}
:root.dark {
/* Modo oscuro */
--theme-body: #0d0d0d;
--theme-background: #1a1a1a;
--theme-titulo: #e8e4d8;
--theme-texto: #b8b3a3;
--theme-fill: #2d2d2d;
--theme-borde: rgba(255, 255, 255, 0.1);
--theme-accent: #c9a751;
--theme-accent-hover: #d4b76a;
/* Superficies alternativas */
--theme-surface: #1a1a1a;
--theme-surface-hover: #2d2d2d;
}
/* ============================================
ESTILOS BASE
============================================ */
......@@ -115,22 +152,51 @@ html {
body {
font-family: var(--font-sans);
color: var(--color-graphite);
background-color: #FAFAFA;
color: var(--theme-titulo);
background-color: var(--theme-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Focus ring consistente con identidad */
*:focus-visible {
outline: 2px solid var(--color-gold);
outline: 2px solid var(--theme-accent);
outline-offset: 2px;
}
/* Selección de texto */
::selection {
background-color: var(--color-gold);
color: white;
background-color: var(--theme-accent);
color: var(--theme-body);
}
/* ============================================
UTILIDADES DE TEMA
============================================ */
.theme-bg {
background-color: var(--theme-body);
}
.theme-surface {
background-color: var(--theme-surface);
}
.theme-text {
color: var(--theme-titulo);
}
.theme-text-muted {
color: var(--theme-texto);
}
.theme-border {
border-color: var(--theme-borde);
}
.theme-accent {
color: var(--theme-accent);
}
/* ============================================
......
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Theme initialization - ANTES de cargar CSS para evitar flash -->
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark');
}
// Por defecto modo claro (no detectar sistema)
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
......
<script>
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { drawerOpen, closeDrawer } from '$lib/stores/drawer.js';
let isDarkMode = $state(false);
const clasificadores = [
{ href: '/clasificadores/institucional', nombre: 'Institucional', desc: 'Quién gasta' },
{ href: '/clasificadores/objeto-gasto', nombre: 'Objeto del Gasto', desc: 'En qué' },
{ href: '/clasificadores/rubros', nombre: 'Rubros', desc: 'Cuánto ingresa' },
{ href: '/clasificadores/funcional', nombre: 'Funcional', desc: 'Para qué' },
{ href: '/clasificadores/fuente', nombre: 'Fuente', desc: 'De dónde' },
{ href: '/clasificadores/organismo', nombre: 'Organismo', desc: 'Quién financia' },
{ href: '/clasificadores/sectores', nombre: 'Sectores', desc: 'Qué sector' },
];
function isActive(href) {
return $page.url.pathname.startsWith(href);
}
function toggleTheme() {
isDarkMode = !isDarkMode;
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}
onMount(() => {
isDarkMode = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDarkMode = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
return () => observer.disconnect();
});
</script>
<!-- Overlay (fondo oscuro) -->
{#if $drawerOpen}
<div
class="fixed inset-0 bg-black/40 z-[200]"
style="opacity: {$drawerOpen ? '1' : '0'}; transition: opacity 0.2s ease-out;"
onclick={closeDrawer}
onkeydown={(e) => e.key === 'Escape' && closeDrawer()}
role="button"
tabindex="0"
aria-label="Cerrar menú"
></div>
{/if}
<!-- Drawer -->
<aside
class="fixed top-0 right-0 h-full w-80 border-l shadow-2xl z-[201] flex flex-col"
style="
background-color: var(--theme-body);
border-color: rgba(0, 0, 0, 0.05);
transform: {$drawerOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)'};
transition: transform 0.3s cubic-bezier(0.33, 1, 0.68, 1);
"
>
<!-- Header del Drawer -->
<div class="py-3 md:py-4 px-4 md:px-6 border-b flex-shrink-0 relative" style="border-color: rgba(0, 0, 0, 0.05);">
<!-- Theme Toggle - SOLO en móvil (arriba izquierda del drawer) -->
<div class="absolute top-3 md:top-4 left-4 md:left-6 md:hidden">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label={isDarkMode ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDarkMode}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
</div>
<!-- Botón cerrar (X) - arriba derecha -->
<div class="group absolute top-3 md:top-4 right-4 md:right-6">
<button
onclick={closeDrawer}
class="p-2 rounded-lg transition-colors flex-shrink-0"
style="color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label="Cerrar menú"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Tooltip del botón cerrar -->
<div class="absolute left-1/2 -translate-x-1/2 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:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Cerrar menú
<div class="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- Logo centrado (cambia según tema) -->
<div class="flex flex-col items-center pt-8">
{#if isDarkMode}
<img
src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png"
alt="MEFP"
class="h-16 md:h-20 w-auto mb-3"
/>
{:else}
<img
src="/logos_claro/06_Isologo MEFP horizontal escala de grises.png"
alt="MEFP"
class="h-16 md:h-20 w-auto mb-3"
/>
{/if}
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto p-4">
<!-- Inicio -->
<a
href="/"
onclick={closeDrawer}
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left mb-4"
style="background-color: {$page.url.pathname === '/' ? 'var(--theme-fill)' : 'transparent'};"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = $page.url.pathname === '/' ? 'var(--theme-fill)' : 'transparent'}
>
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--theme-texto);" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</svg>
<span class="font-medium text-sm" style="color: var(--theme-titulo);">Página principal</span>
</a>
<!-- Clasificadores -->
<div class="mb-6">
<h3 class="text-xs font-semibold uppercase tracking-wider mb-3 px-2" style="color: var(--theme-texto);">
Clasificadores
</h3>
<div class="space-y-1">
{#each clasificadores as c}
<a
href={c.href}
onclick={closeDrawer}
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-colors text-left group"
style="background-color: {isActive(c.href) ? 'var(--theme-fill)' : 'transparent'};"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = isActive(c.href) ? 'var(--theme-fill)' : 'transparent'}
>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium block" style="color: var(--theme-titulo);">{c.nombre}</span>
<span class="text-xs" style="color: var(--theme-texto);">{c.desc}</span>
</div>
<svg
class="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style="color: var(--theme-texto);"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>
</a>
{/each}
</div>
</div>
<!-- Ver todos los clasificadores -->
<a
href="/clasificadores"
onclick={closeDrawer}
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left mb-6"
style="background-color: {$page.url.pathname === '/clasificadores' ? 'var(--theme-fill)' : 'transparent'};"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = $page.url.pathname === '/clasificadores' ? 'var(--theme-fill)' : 'transparent'}
>
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>
</svg>
<span class="text-sm" style="color: var(--theme-accent);">Ver todos los clasificadores</span>
</a>
<!-- Separador -->
<div class="border-t my-4" style="border-color: var(--theme-borde);"></div>
<!-- Enlaces secundarios -->
<div class="space-y-1">
<a
href="/metodologia"
onclick={closeDrawer}
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--theme-texto);" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/>
</svg>
<span class="text-sm" style="color: var(--theme-titulo);">Metodología</span>
</a>
<a
href="/acerca"
onclick={closeDrawer}
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--theme-texto);" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/>
</svg>
<span class="text-sm" style="color: var(--theme-titulo);">Acerca de</span>
</a>
</div>
</nav>
<!-- Footer -->
<div class="p-4 border-t flex-shrink-0" style="border-color: rgba(0, 0, 0, 0.05);">
<p class="text-xs text-center" style="color: var(--theme-texto);">
Ministerio de Economía y Finanzas Públicas
</p>
</div>
</aside>
......@@ -10,12 +10,21 @@
error,
selectedIndex,
showResults,
filterType,
initIndex,
performSearch,
clearSearch,
navigateResults
navigateResults,
setFilter
} from '$lib/stores/searchStore';
const filterOptions = [
{ value: 'todos', label: 'Todos' },
{ value: 'entidad', label: 'Entidades' },
{ value: 'objeto_gasto', label: 'Objetos' },
{ value: 'rubro', label: 'Rubros' }
];
let inputRef;
let debounceTimer;
let isFocused = $state(false);
......@@ -77,16 +86,28 @@
<svelte:window onclick={handleClickOutside} />
<div class="search-container relative">
<input
bind:this={inputRef}
type="text"
placeholder={$indexLoaded ? "Buscar entidad, objeto de gasto o rubro..." : "Cargando..."}
disabled={!$indexLoaded}
class="w-full p-2 border rounded"
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => isFocused = true}
/>
<div class="flex gap-2">
<input
bind:this={inputRef}
type="text"
placeholder={$indexLoaded ? "Buscar..." : "Cargando..."}
disabled={!$indexLoaded}
class="flex-1 p-2 border rounded"
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => isFocused = true}
/>
<select
class="px-3 py-2 border rounded bg-white text-sm text-slate-600 cursor-pointer"
value={$filterType}
onchange={(e) => setFilter(e.target.value)}
disabled={!$indexLoaded}
>
{#each filterOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
{#if $isLoading}
<span class="absolute right-2 top-2 text-sm text-gray-400">...</span>
......
<script>
import { goto } from '$app/navigation';
import { clearSearch } from '$lib/stores/searchStore';
import { clearSearch, filterType } from '$lib/stores/searchStore';
let { results = [], query = '', selectedIndex = -1 } = $props();
......@@ -23,6 +23,9 @@
return { entidades, objetos, rubros };
});
// Si hay un filtro específico, no mostrar headers de grupo
let showGroupHeaders = $derived($filterType === 'todos');
// Calcular índice global para navegación con teclado
function getGlobalIndex(tipo, localIndex) {
const g = groupedResults();
......@@ -39,9 +42,11 @@
{#if results.length > 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg max-h-80 overflow-y-auto z-50">
{#if groupedResults().entidades.length > 0}
<div class="px-3 py-1.5 bg-slate-50 border-b">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Entidades</span>
</div>
{#if showGroupHeaders}
<div class="px-3 py-1.5 bg-slate-50 border-b">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Entidades</span>
</div>
{/if}
{#each groupedResults().entidades as item, index}
{@const globalIdx = getGlobalIndex('entidad', index)}
<button
......@@ -61,9 +66,11 @@
{/if}
{#if groupedResults().objetos.length > 0}
<div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 ? 'border-t' : ''}">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Objetos del Gasto</span>
</div>
{#if showGroupHeaders}
<div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 ? 'border-t' : ''}">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Objetos del Gasto</span>
</div>
{/if}
{#each groupedResults().objetos as item, index}
{@const globalIdx = getGlobalIndex('objeto_gasto', index)}
<button
......@@ -81,9 +88,11 @@
{/if}
{#if groupedResults().rubros.length > 0}
<div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 || groupedResults().objetos.length > 0 ? 'border-t' : ''}">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Rubros</span>
</div>
{#if showGroupHeaders}
<div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 || groupedResults().objetos.length > 0 ? 'border-t' : ''}">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Rubros</span>
</div>
{/if}
{#each groupedResults().rubros as item, index}
{@const globalIdx = getGlobalIndex('rubro', index)}
<button
......
<script>
import { page } from '$app/stores';
import { onMount } from 'svelte';
let { onOpenDrawer = () => {} } = $props();
let isDark = $state(false);
function toggleTheme() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
onMount(() => {
// Read actual state from document (set by app.html)
isDark = document.documentElement.classList.contains('dark');
// Listen for theme changes (e.g., from drawer on mobile)
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
return () => observer.disconnect();
});
</script>
<nav class="border-b p-4">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<a href="/">Presupuesto Público Bolivia</a>
<div class="flex gap-4 text-sm">
<a href="/">Inicio</a>
<a href="/clasificadores">Clasificadores</a>
<!-- MÓVIL: Solo hamburguesa arriba a la derecha -->
<div class="fixed top-4 right-4 z-[110] md:hidden">
<button
onclick={onOpenDrawer}
class="p-2 rounded-full transition-all flex-shrink-0"
style="background-color: transparent; color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
</div>
<!-- DESKTOP: Barra completa con Home + Theme + Menu -->
<div class="hidden md:flex fixed top-6 right-6 z-[110]">
<div class="flex items-center gap-1">
<!-- BOTÓN HOME (4 cuadrados) -->
<div class="relative group/home">
<a
href="/"
class="p-2 rounded-full transition-all block"
style="background-color: transparent; color: var(--theme-texto);"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</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">
Página principal
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- BOTÓN THEME TOGGLE (Sol/Luna) -->
<div class="relative group/theme">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDark}
<!-- LUNA (modo oscuro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<!-- SOL (modo claro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
<!-- 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/theme:opacity-100 group-hover/theme:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
{isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- BOTÓN MENÚ HAMBURGUESA -->
<div class="relative group/menu">
<button
onclick={onOpenDrawer}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
<!-- 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/menu:opacity-100 group-hover/menu:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Menú
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
</div>
</nav>
</div>
......
import { writable } from 'svelte/store';
export const drawerOpen = writable(false);
export function openDrawer() {
drawerOpen.set(true);
}
export function closeDrawer() {
drawerOpen.set(false);
}
export function toggleDrawer() {
drawerOpen.update(v => !v);
}
import { writable, derived } from 'svelte/store';
import { writable, derived, get } from 'svelte/store';
import { initSearchIndex, search, isIndexReady } from '$lib/services/search';
// Estado del buscador
......@@ -8,6 +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
// Estado derivado: si está mostrando resultados
export const showResults = derived(
......@@ -41,7 +42,7 @@ export async function initIndex() {
/**
* Realiza la búsqueda
*/
export function performSearch(searchQuery) {
export function performSearch(searchQuery, filter = null) {
query.set(searchQuery);
selectedIndex.set(-1);
......@@ -50,8 +51,16 @@ export function performSearch(searchQuery) {
return;
}
const searchResults = search(searchQuery, 15);
results.set(searchResults);
let searchResults = search(searchQuery, 30);
// Filtrar por tipo si no es "todos"
const currentFilter = filter !== null ? filter : get(filterType);
if (currentFilter !== 'todos') {
searchResults = searchResults.filter(r => r.tipo === currentFilter);
}
// Limitar resultados
results.set(searchResults.slice(0, 15));
}
/**
......@@ -64,6 +73,18 @@ export function clearSearch() {
}
/**
* Cambia el filtro de tipo
*/
export function setFilter(type) {
filterType.set(type);
// Re-ejecutar búsqueda con el nuevo filtro
const currentQuery = get(query);
if (currentQuery.length >= 2) {
performSearch(currentQuery, type);
}
}
/**
* Navegación por teclado
*/
export function navigateResults(direction, totalResults) {
......
<script>
import '../app.css';
import { page } from '$app/stores';
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/components/ui/Navbar.svelte';
import NavigationDrawer from '$lib/components/layout/NavigationDrawer.svelte';
import { openDrawer } from '$lib/stores/drawer.js';
let { children } = $props();
// Don't show navbar on landing page
let isLanding = $derived($page.url.pathname === '/');
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<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 flex flex-col">
<Navbar />
<main class="flex-1">
<!-- Navigation Drawer (siempre presente) -->
<NavigationDrawer />
<!-- Floating controls bar (fixed position - no afecta el flujo) -->
{#if !isLanding}
<Navbar onOpenDrawer={openDrawer} />
{/if}
<div class="min-h-screen" style="background-color: var(--theme-body);">
<main>
{@render children()}
</main>
</div>
......
<script>
import SearchBox from '$lib/components/search/SearchBox.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { openDrawer } from '$lib/stores/drawer.js';
import {
query,
results,
isLoading,
indexLoaded,
error,
selectedIndex,
showResults,
initIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
// State - default to archivo (historical mode)
let mode = $state('archivo');
let searchFocused = $state(false);
let hoveredStory = $state(null);
let mousePos = $state({ x: 0, y: 0 });
let activeToggle = $state(null);
let searchInput = $state(null);
let debounceTimer;
let isDarkMode = $state(true);
// Visibility tracking for reveal animations
let revealStates = $state({});
// Dynamic colors that respond to theme
// We'll use CSS variables but keep these for JS-driven effects
const COLORS = {
accent: '#C9A751',
accentDim: '#B89A2A',
red: '#C44B3F',
green: '#4A8B6E',
};
const MODES = {
archivo: {
placeholder: 'YPFB, Publicidad, Sueldos...',
label: 'Serie histórica',
color: COLORS.accent,
},
hoy: {
placeholder: 'Tu municipio, Salud, Obras...',
label: 'Actualización diaria',
color: COLORS.green,
},
};
const STORIES = [
{ tag: 'INVESTIGACION', accent: COLORS.accent, title: '¿Cuanto gasta Bolivia en consultorias cada año?', desc: 'Un analisis de 20 años de contratos de consultoria revela patrones que todo ciudadano deberia conocer.' },
{ tag: 'TRANSPARENCIA', accent: COLORS.green, title: 'El presupuesto de tu municipio, peso por peso', desc: 'Cada boliviano invertido en tu municipio, rastreado desde su asignacion hasta su ejecucion final.' },
{ tag: 'DATOS', accent: COLORS.red, title: 'Salud vs. Defensa: 20 años de prioridades', desc: 'Como han cambiado las prioridades presupuestarias de Bolivia a lo largo de dos decadas.' },
];
let cur = $derived(MODES[mode]);
function handleSwitch(m) {
mode = m;
clearSearch();
if (searchInput) searchInput.value = '';
setTimeout(() => searchInput?.focus(), 150);
}
function handleToggleHover(m) {
activeToggle = m;
}
function handleToggleLeave() {
activeToggle = null;
}
function handleBlur() {
setTimeout(() => { searchFocused = false; }, 200);
}
function handleInput(e) {
const value = e.target.value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
performSearch(value);
}, 250);
}
function handleKeydown(e) {
if (!$showResults) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
navigateResults('down', $results.length);
break;
case 'ArrowUp':
e.preventDefault();
navigateResults('up', $results.length);
break;
case 'Enter':
e.preventDefault();
if ($selectedIndex >= 0 && $results[$selectedIndex]) {
handleSelect($results[$selectedIndex]);
}
break;
case 'Escape':
clearSearch();
if (searchInput) searchInput.value = '';
searchInput?.blur();
break;
}
}
function handleSelect(item) {
if (item.tipo === 'entidad') {
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
}
clearSearch();
if (searchInput) searchInput.value = '';
}
function getTypeLabel(tipo) {
switch (tipo) {
case 'entidad': return 'ENTIDAD';
case 'objeto_gasto': return 'OBJETO DE GASTO';
case 'rubro': return 'RUBRO';
default: return tipo.toUpperCase();
}
}
// Intersection observer for reveals
function reveal(node, { id, delay = 0 }) {
revealStates[id] = false;
revealStates = revealStates;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => {
revealStates[id] = true;
revealStates = revealStates;
}, delay);
observer.disconnect();
}
}, { threshold: 0.15 });
observer.observe(node);
return { destroy() { observer.disconnect(); } };
}
function toggleTheme() {
isDarkMode = !isDarkMode;
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}
onMount(() => {
// Initialize search index
initIndex();
// Check initial theme
isDarkMode = document.documentElement.classList.contains('dark');
// Listen for theme changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
isDarkMode = document.documentElement.classList.contains('dark');
}
});
});
observer.observe(document.documentElement, { attributes: true });
const handleMouse = (e) => { mousePos = { x: e.clientX, y: e.clientY }; };
window.addEventListener('mousemove', handleMouse);
return () => {
window.removeEventListener('mousemove', handleMouse);
observer.disconnect();
};
});
</script>
<svelte:head>
<title>Presupuesto Público Bolivia</title>
<title>Presupuesto Abierto — Bolivia</title>
</svelte:head>
<div class="max-w-4xl mx-auto p-4">
<h1 class="text-2xl mb-6">Presupuesto Público de Bolivia</h1>
<div class="mb-8">
<SearchBox />
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<a href="/clasificadores" class="p-4 border rounded hover:bg-gray-50">
<strong>Clasificadores</strong>
<p class="text-gray-600">Navegar por instituciones, objetos de gasto, etc.</p>
</a>
<a href="/historias" class="p-4 border rounded hover:bg-gray-50">
<strong>Historias</strong>
<p class="text-gray-600">Análisis y visualizaciones del gasto público</p>
</a>
</div>
<div class="page">
<!-- Grain overlay -->
<div class="grain"></div>
<!-- Header con Logo y Controles -->
<header class="page-header">
{#if isDarkMode}
<img
src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png"
alt="Ministerio de Economia y Finanzas Publicas"
class="mefp-logo"
/>
{:else}
<img
src="/logos_claro/06_Isologo MEFP horizontal escala de grises.png"
alt="Ministerio de Economia y Finanzas Publicas"
class="mefp-logo"
/>
{/if}
<!-- Controles: Theme Toggle + Menú -->
<div class="flex items-center gap-1">
<!-- Theme Toggle (solo desktop) -->
<div class="relative group/theme hidden md:block">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label={isDarkMode ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDarkMode}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
<!-- 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/theme:opacity-100 group-hover/theme:visible transition-all duration-200 pointer-events-none backdrop-blur-sm z-50">
{isDarkMode ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- Botón Menú -->
<div class="relative group/menu">
<button
onclick={openDrawer}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--theme-texto); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--theme-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--theme-texto)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
<!-- 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/menu:opacity-100 group-hover/menu:visible transition-all duration-200 pointer-events-none backdrop-blur-sm z-50">
Menú
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
</div>
</header>
<!-- Hero -->
<section class="hero">
<div
class="mouse-glow"
style="background: radial-gradient(800px circle at {mousePos.x}px {mousePos.y}px, rgba(201, 167, 81, 0.03), transparent 60%)"
></div>
<div class="hero-content">
<!-- Main title -->
<h1 class="hero-title fade-in" style="animation-delay: 0.3s">
Presupuesto Abierto
</h1>
<!-- Subtitle - the scale -->
<p class="hero-subtitle fade-in" style="animation-delay: 0.5s">
Todos los ingresos y gastos del Estado boliviano.<br />
<span class="subtitle-stats">600+ entidades · 500+ rubros · 21 años · 9M de registros · actualización diaria</span>
</p>
<!-- Search - main CTA -->
<div class="search-wrapper fade-in" style="animation-delay: 0.6s">
<div
class="search-bar"
class:search-focused={searchFocused}
class:search-has-results={searchFocused && $results.length > 0}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
class="search-icon"
class:search-icon-focused={searchFocused}
stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<input
bind:this={searchInput}
type="text"
placeholder={$indexLoaded ? cur.placeholder : "Cargando indice..."}
disabled={!$indexLoaded}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => searchFocused = true}
onblur={handleBlur}
class="search-input"
/>
{#if $query.length > 0}
<button class="clear-btn" onclick={() => { clearSearch(); if (searchInput) searchInput.value = ''; }}>x</button>
{/if}
</div>
<!-- Loading indicator -->
{#if $isLoading}
<div class="search-loading">Buscando...</div>
{/if}
<!-- Results dropdown -->
{#if searchFocused && $results.length > 0}
<div class="suggestions">
{#each $results as item, i}
<button
type="button"
class="suggestion-item"
class:suggestion-border={i > 0}
class:suggestion-selected={$selectedIndex === i}
onclick={() => handleSelect(item)}
>
<div class="suggestion-main">
<span class="suggestion-label">{item.nombre}</span>
{#if item.sigla}
<span class="suggestion-sigla">({item.sigla})</span>
{/if}
</div>
<div class="suggestion-meta">
{#if item.codigo}
<span class="suggestion-code">{item.codigo}</span>
{/if}
<span class="suggestion-type">{getTypeLabel(item.tipo)}</span>
</div>
</button>
{/each}
</div>
{:else if searchFocused && $query.length >= 2 && $results.length === 0 && !$isLoading}
<div class="suggestions no-results">
<div class="no-results-text">Sin resultados para "{$query}"</div>
</div>
{/if}
<!-- Error message -->
{#if $error}
<div class="search-error">{$error}</div>
{/if}
{#if !searchFocused || ($results.length === 0 && $query.length < 2)}
<a href="/clasificadores" class="browse-link">
o explora por clasificadores <span class="browse-arrow">→</span>
</a>
{/if}
</div>
<!-- Mode toggle -->
<div class="mode-toggle-wrapper fade-in" style="animation-delay: 0.8s">
<span class="toggle-hint">Elige el periodo</span>
<div class="mode-toggle">
<button
type="button"
class="toggle-btn"
class:toggle-active={mode === 'archivo'}
class:toggle-hover={activeToggle === 'archivo' && mode !== 'archivo'}
onclick={() => handleSwitch('archivo')}
onmouseenter={() => handleToggleHover('archivo')}
onmouseleave={handleToggleLeave}
>
<span class="toggle-label">Serie histórica</span>
<span class="toggle-years">2005–2025</span>
</button>
<span class="toggle-divider"></span>
<button
type="button"
class="toggle-btn"
class:toggle-active={mode === 'hoy'}
class:toggle-hover={activeToggle === 'hoy' && mode !== 'hoy'}
onclick={() => handleSwitch('hoy')}
onmouseenter={() => handleToggleHover('hoy')}
onmouseleave={handleToggleLeave}
>
<span class="toggle-label">Actualización diaria</span>
<span class="toggle-years">2025</span>
</button>
</div>
</div>
</div>
<!-- Scroll indicator -->
<div class="scroll-indicator fade-in" style="animation-delay: 1.2s">
<span>SCROLL</span>
<div class="scroll-line"></div>
</div>
</section>
<!-- Stories -->
<section class="stories-section" id="historias">
<div
use:reveal={{ id: 'stories-header' }}
class="stories-header"
class:revealed={revealStates['stories-header']}
>
<div>
<h2 class="stories-title-big">Lecturas del presupuesto</h2>
</div>
<a href="/historias" class="stories-link">Ver todas →</a>
</div>
<div class="stories-grid">
{#each STORIES as story, i}
<div
use:reveal={{ id: `story-${i}`, delay: i * 150 }}
class="story-card"
class:revealed={revealStates[`story-${i}`]}
class:story-hovered={hoveredStory === i}
onmouseenter={() => hoveredStory = i}
onmouseleave={() => hoveredStory = null}
role="article"
>
<div class="story-accent-line" style="background: {story.accent}; transform: scaleX({hoveredStory === i ? 1 : 0})"></div>
<div class="story-content">
<span class="story-tag" style="color: {story.accent}">{story.tag}</span>
<h3 class="story-card-title">{story.title}</h3>
<p class="story-desc">{story.desc}</p>
</div>
<div class="story-cta" class:story-cta-active={hoveredStory === i}>
Leer historia <span class="story-arrow" class:arrow-moved={hoveredStory === i}>→</span>
</div>
</div>
{/each}
</div>
</section>
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,300;8..60,400;8..60,600;8..60,700;8..60,900&family=JetBrains+Mono:wght@300;400;500&family=DM+Sans:wght@400;500;600;700&display=swap');
.page * { margin: 0; padding: 0; box-sizing: border-box; }
.page ::selection { background: var(--theme-accent); color: var(--theme-body); }
.page::-webkit-scrollbar { width: 6px; }
.page::-webkit-scrollbar-track { background: var(--theme-body); }
.page::-webkit-scrollbar-thumb { background: var(--theme-texto); border-radius: 3px; }
@keyframes grain { 0%,100%{transform:translate(0,0)}10%{transform:translate(-2%,-3%)}30%{transform:translate(-1%,3%)}50%{transform:translate(-3%,1%)}70%{transform:translate(-2%,2%)}90%{transform:translate(-1%,2%)} }
@keyframes fadeInUp { from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)} }
.page {
background: var(--theme-body);
color: var(--theme-titulo);
min-height: 100vh;
font-family: 'Source Serif 4', Georgia, serif;
overflow-x: hidden;
transition: background-color 0.3s, color 0.3s;
}
/* Grain - only visible in dark mode */
.grain {
position: fixed; top: -50%; left: -50%; width: 200%; height: 200%;
pointer-events: none; z-index: 9999; opacity: 0.03;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation: grain 0.5s steps(1) infinite;
}
:global(:root:not(.dark)) .grain { display: none; }
/* Header with MEFP logo */
.page-header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
padding: 24px 40px;
background: linear-gradient(180deg, var(--theme-body) 0%, transparent 100%);
display: flex; align-items: center; justify-content: space-between;
}
.mefp-logo {
height: 80px; width: auto;
opacity: 0.9;
transition: opacity 0.3s;
}
.mefp-logo:hover { opacity: 1; }
/* Fade in animation */
.fade-in {
opacity: 0;
animation: fadeInUp 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation-fill-mode: forwards;
}
/* Hero */
.hero {
min-height: 100vh; display: flex; flex-direction: column; justify-content: center;
padding: 80px 40px 60px; position: relative; max-width: 1200px; margin: 0 auto;
}
.mouse-glow {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none; z-index: 0; transition: background 0.6s;
}
:global(:root:not(.dark)) .mouse-glow { display: none; }
.hero-content {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center;
text-align: center;
}
/* Title */
.hero-title {
font-size: clamp(40px, 7vw, 76px); font-weight: 700;
line-height: 1.05; letter-spacing: -0.02em;
color: var(--theme-titulo);
}
/* Subtitle */
.hero-subtitle {
margin-top: 24px; max-width: 720px;
font-size: clamp(17px, 2vw, 20px); line-height: 1.6;
color: var(--theme-texto); font-weight: 300;
}
.subtitle-stats {
display: block; margin-top: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: clamp(11px, 1.3vw, 13px);
letter-spacing: 0.08em;
color: var(--theme-accent);
}
/* Mode toggle - equal hierarchy */
.mode-toggle-wrapper {
display: flex; flex-direction: column; align-items: center;
margin-top: 56px; gap: 12px;
position: relative; z-index: 10;
}
.toggle-hint {
font-family: 'JetBrains Mono', monospace;
font-size: 9px; letter-spacing: 0.15em;
text-transform: uppercase; color: var(--theme-texto);
opacity: 0.6;
}
.mode-toggle {
display: flex; align-items: center; gap: 0;
}
.toggle-btn {
display: flex; flex-direction: column; align-items: flex-start;
padding: 10px 18px;
background: transparent; border: 1px solid var(--theme-borde);
cursor: pointer; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.toggle-btn:first-child {
border-radius: 6px 0 0 6px;
border-right: none;
}
.toggle-btn:last-child {
border-radius: 0 6px 6px 0;
}
.toggle-btn.toggle-active {
background: var(--theme-fill);
border-color: var(--theme-borde);
}
.toggle-btn.toggle-hover {
background: var(--theme-fill);
border-color: var(--theme-borde);
}
.toggle-label {
font-family: 'DM Sans', sans-serif; font-size: 12px;
color: var(--theme-texto); font-weight: 500;
transition: color 0.3s;
}
.toggle-btn.toggle-active .toggle-label {
color: var(--theme-titulo);
}
.toggle-btn.toggle-hover .toggle-label {
color: var(--theme-titulo);
opacity: 0.7;
}
.toggle-years {
font-family: 'JetBrains Mono', monospace; font-size: 9px;
letter-spacing: 0.08em; color: var(--theme-texto);
opacity: 0.5;
margin-top: 2px; transition: color 0.3s;
}
.toggle-btn.toggle-active .toggle-years {
color: var(--theme-texto);
opacity: 0.8;
}
.toggle-divider {
width: 1px; height: 36px; background: var(--theme-borde);
}
.browse-link {
display: block;
margin-top: 14px;
font-family: 'DM Sans', sans-serif;
font-size: 12px; color: var(--theme-texto);
text-decoration: none;
text-align: center;
transition: color 0.2s;
}
.browse-link:hover { color: var(--theme-accent); }
.browse-link:hover .browse-arrow { transform: translateX(3px); }
.browse-arrow {
display: inline-block;
transition: transform 0.2s;
}
/* Search - main CTA */
.search-wrapper { margin-top: 28px; width: 100%; max-width: 520px; position: relative; z-index: 60; }
.search-bar {
display: flex; align-items: center;
background: var(--theme-surface); border: 1px solid var(--theme-borde);
border-radius: 12px; padding: 6px 24px;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.search-focused { background: var(--theme-fill); }
.search-has-results { border-radius: 12px 12px 0 0; }
.search-bar input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--theme-titulo); font-size: 16px; font-family: 'DM Sans', sans-serif;
padding: 16px 14px; caret-color: var(--theme-accent);
}
.search-bar input::placeholder { color: var(--theme-texto); opacity: 0.6; }
.search-bar input:disabled { opacity: 0.5; cursor: not-allowed; }
.search-icon { stroke: var(--theme-texto); transition: stroke 0.3s; }
.search-icon-focused { stroke: var(--theme-accent); }
.clear-btn {
background: none; border: none; cursor: pointer; color: var(--theme-texto);
font-size: 20px; padding: 4px 0 4px 8px; line-height: 1; transition: color 0.2s;
}
.clear-btn:hover { color: var(--theme-titulo); }
.search-loading {
position: absolute; top: 100%; left: 0; right: 0;
background: var(--theme-surface); border: 1px solid var(--theme-borde); border-top: none;
border-radius: 0 0 14px 14px; padding: 12px 24px;
font-family: 'DM Sans', sans-serif; font-size: 13px; color: var(--theme-texto);
}
.search-error {
margin-top: 8px; font-family: 'DM Sans', sans-serif;
font-size: 13px; color: #C44B3F;
}
/* Suggestions / Results */
.suggestions {
position: absolute; top: 100%; left: 0; right: 0;
background: var(--theme-surface); border: 1px solid var(--theme-borde); border-top: none;
border-radius: 0 0 14px 14px; overflow: hidden; z-index: 100;
max-height: 320px; overflow-y: auto;
}
.suggestions.no-results { padding: 16px 24px; }
.no-results-text {
font-family: 'DM Sans', sans-serif; font-size: 14px; color: var(--theme-texto);
}
.suggestion-item {
width: 100%; text-align: left; background: none; border: none;
padding: 12px 24px; display: flex; justify-content: space-between;
align-items: center; cursor: pointer; transition: background 0.2s;
}
.suggestion-item:hover, .suggestion-selected { background: var(--theme-fill); }
.suggestion-border { border-top: 1px solid var(--theme-borde); }
.suggestion-main { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.suggestion-label {
font-family: 'DM Sans', sans-serif; font-size: 14px; color: var(--theme-titulo);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.suggestion-sigla {
font-family: 'DM Sans', sans-serif; font-size: 12px; color: var(--theme-texto);
flex-shrink: 0;
}
.suggestion-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; margin-left: 12px; }
.suggestion-code {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
color: var(--theme-texto); background: var(--theme-body); padding: 2px 6px; border-radius: 3px;
}
.suggestion-type {
font-family: 'JetBrains Mono', monospace; font-size: 10px;
letter-spacing: 0.1em; color: var(--theme-texto); background: var(--theme-body);
padding: 3px 8px; border-radius: 4px;
}
/* Scroll indicator */
.scroll-indicator {
position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.scroll-indicator span {
font-family: 'JetBrains Mono', monospace; font-size: 9px;
letter-spacing: 0.3em; color: var(--theme-texto);
}
.scroll-line {
width: 1px; height: 40px;
background: linear-gradient(180deg, var(--theme-texto), transparent);
}
/* Ticker */
.ticker-wrapper {
overflow: hidden; padding: 14px 0; background: var(--theme-surface);
border-top: 1px solid var(--theme-borde); border-bottom: 1px solid var(--theme-borde);
}
.ticker-track {
display: flex; gap: 32px; white-space: nowrap;
animation: ticker 30s linear infinite; width: fit-content;
}
.ticker-item {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
letter-spacing: 0.15em; color: var(--theme-texto);
}
.ticker-diamond { color: var(--theme-accent); }
/* Reveal animation */
.revealed { opacity: 1 !important; transform: translateY(0) !important; }
/* Stories */
.stories-section { padding: 100px 40px; max-width: 1200px; margin: 0 auto; }
.stories-header {
display: flex; justify-content: space-between; align-items: flex-end;
margin-bottom: 56px;
opacity: 0; transform: translateY(40px);
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.stories-title-big {
font-family: 'Archivo Condensed', system-ui, sans-serif;
font-size: clamp(28px, 4vw, 48px); font-weight: 700;
line-height: 1.1; letter-spacing: -0.01em;
text-transform: uppercase;
color: var(--theme-titulo);
}
.text-muted { color: var(--theme-texto); }
.stories-link {
font-family: 'DM Sans', sans-serif; font-size: 13px; color: var(--theme-accent);
text-decoration: none; display: flex; align-items: center; gap: 6px;
letter-spacing: 0.05em;
}
.stories-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--theme-borde); }
.story-card {
background: var(--theme-body); padding: 40px 32px; cursor: pointer;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
position: relative; min-height: 280px;
display: flex; flex-direction: column; justify-content: space-between;
opacity: 0; transform: translateY(40px);
}
.story-card.revealed { opacity: 1; transform: translateY(0); }
.story-hovered { background: var(--theme-fill); }
.story-accent-line {
position: absolute; top: 0; left: 0; right: 0; height: 3px;
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.story-tag {
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 0.2em;
}
.story-card-title {
font-size: 22px; font-weight: 700; line-height: 1.25;
margin-top: 16px; letter-spacing: -0.01em;
color: var(--theme-titulo);
}
.story-desc {
font-family: 'DM Sans', sans-serif; font-size: 14px;
color: var(--theme-texto); line-height: 1.6; margin-top: 12px;
}
.story-cta {
font-family: 'DM Sans', sans-serif; font-size: 13px;
color: var(--theme-texto); transition: color 0.3s; margin-top: 24px;
display: flex; align-items: center; gap: 6px;
}
.story-cta-active { color: var(--theme-titulo); }
.story-arrow { transition: transform 0.3s; display: inline-block; }
.arrow-moved { transform: translateX(4px); }
/* Responsive */
@media (max-width: 1024px) {
.stories-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.page-header { padding: 16px 20px; }
.mefp-logo { height: 52px; }
.hero { padding: 100px 20px 40px; }
.subtitle-stats { font-size: 11px; }
.hero-title { font-size: clamp(32px, 8vw, 42px); }
.mode-toggle-wrapper { margin-top: 40px; }
.mode-toggle { flex-direction: column; gap: 0; }
.toggle-btn:first-child { border-radius: 6px 6px 0 0; border-right: 1px solid var(--theme-borde); border-bottom: none; }
.toggle-btn:last-child { border-radius: 0 0 6px 6px; }
.toggle-divider { width: 100%; height: 1px; }
.stories-section { padding-left: 20px; padding-right: 20px; }
.stories-grid { grid-template-columns: 1fr; }
.stories-header { flex-direction: column; align-items: flex-start; gap: 16px; }
}
</style>
......
<script>
const clasificadores = [
{ id: 'institucional', nombre: 'Institucional', descripcion: 'Ministerios, universidades, municipios, empresas públicas', disponible: true },
{ id: 'objeto-gasto', nombre: 'Objeto del Gasto', descripcion: 'Servicios personales, bienes, inversiones', disponible: true },
{ id: 'rubros', nombre: 'Rubros de Ingresos', descripcion: 'Ingresos tributarios, no tributarios, transferencias', disponible: true },
{ id: 'fuente', nombre: 'Fuente de Financiamiento', descripcion: 'Tesoro, créditos externos, recursos propios', disponible: false },
{ id: 'organismo', nombre: 'Organismo Financiador', descripcion: 'BID, Banco Mundial, CAF', disponible: false },
{ id: 'geografico', nombre: 'Geográfico', descripcion: 'Departamentos, provincias, municipios', disponible: false },
{ id: 'funcional', nombre: 'Funcional', descripcion: 'Educación, salud, defensa', disponible: false },
{ id: 'economico', nombre: 'Económico', descripcion: 'Corrientes, de capital', disponible: false },
{ id: 'programatico', nombre: 'Programático', descripcion: 'Programas y proyectos', disponible: false },
{
slug: "institucional",
ruta: "/clasificadores/institucional",
numero: "01",
nombre: "Institucional",
titulo: "Quién",
desc: "Las instituciones que gastan y administran recursos públicos",
ejemplo: "Ministerios, municipios, YPFB…"
},
{
slug: "objeto-gasto",
ruta: "/clasificadores/objeto-gasto",
numero: "02",
nombre: "Objeto del Gasto",
titulo: "En qué",
desc: "La naturaleza del gasto: sueldos, obras, compras",
ejemplo: "Servicios personales, bienes, inversión…"
},
{
slug: "rubros",
ruta: "/clasificadores/rubros",
numero: "03",
nombre: "Recursos por Rubros",
titulo: "Cuánto ingresa",
desc: "Los ingresos del Estado por tipo de recaudación",
ejemplo: "Impuestos, regalías, tasas…"
},
{
slug: "funcional",
ruta: "/clasificadores/funcional",
numero: "04",
nombre: "Finalidad y Función",
titulo: "Para qué",
desc: "El propósito del gasto según funciones del Estado",
ejemplo: "Salud, Educación, Defensa…"
},
{
slug: "fuente",
ruta: "/clasificadores/fuente",
numero: "05",
nombre: "Fuente de Financiamiento",
titulo: "De dónde",
desc: "El origen del dinero que financia cada gasto",
ejemplo: "Tesoro, IDH, créditos externos…"
},
{
slug: "organismo",
ruta: "/clasificadores/organismo",
numero: "06",
nombre: "Organismo Financiador",
titulo: "Quién financia",
desc: "Los organismos detrás de los créditos y donaciones",
ejemplo: "BID, CAF, Banco Mundial…"
},
{
slug: "sectores",
ruta: "/clasificadores/sectores",
numero: "07",
nombre: "Sector Económico",
titulo: "Qué sector",
desc: "Los sectores productivos que activa el gasto",
ejemplo: "Hidrocarburos, agro, transporte…"
},
];
</script>
</script>
<svelte:head>
<title>Clasificadores | Presupuesto Público</title>
<title>Explorar los datos — Presupuesto Bolivia</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="max-w-4xl mx-auto p-4">
<p class="text-sm text-gray-500 mb-4">
<a href="/">Inicio</a> / Clasificadores
</p>
<h1 class="text-2xl mb-6">Clasificadores</h1>
<div class="space-y-2">
{#each clasificadores as item}
{#if item.disponible}
<a href="/clasificadores/{item.id}" class="block p-4 border rounded hover:bg-gray-50">
<strong>{item.nombre}</strong>
<p class="text-sm text-gray-600">{item.descripcion}</p>
</a>
{:else}
<div class="p-4 border rounded opacity-50">
<strong>{item.nombre}</strong>
<span class="text-xs text-gray-400 ml-2">(próximamente)</span>
<p class="text-sm text-gray-600">{item.descripcion}</p>
</div>
{/if}
<main class="clasificadores-page">
<!-- Header de la página -->
<header class="page-header">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a href="/" class="breadcrumb-link">← Inicio</a>
</nav>
<div class="header-content">
<h1>Formas de leer el presupuesto</h1>
<p class="subtitulo">
Cada ingreso y cada gasto del Estado se registra bajo distintas clasificaciones.
Aquí puedes explorar los datos según cada una de ellas.
</p>
</div>
</header>
<!-- Grid de clasificadores -->
<section class="grid-clasificadores">
{#each clasificadores as c}
<a href={c.ruta} class="card">
<span class="card-meta">
<span class="card-numero">{c.numero}</span>
<span class="card-nombre">{c.nombre}</span>
</span>
<h2 class="card-titulo">{c.titulo}</h2>
<p class="card-desc">{c.desc}</p>
<p class="card-ejemplo">{c.ejemplo}</p>
</a>
{/each}
</div>
</div>
</section>
<!-- Footer contextual -->
<footer class="page-footer">
<p class="footnote">
Fuente: SIGMA (Sistema Integrado de Gestión y Modernización Administrativa) y
SIGEP (Sistema de Gestión Pública) — Ministerio de Economía y Finanzas Públicas.
Datos desde 2005.
</p>
</footer>
</main>
<style>
.clasificadores-page {
background: var(--theme-body);
color: var(--theme-titulo);
font-family: 'Instrument Sans', sans-serif;
min-height: 100vh;
max-width: 1000px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
flex-direction: column;
justify-content: center;
transition: background-color 0.2s, color 0.2s;
}
/* ── Header ── */
.page-header {
padding: 1.25rem 0 1.5rem;
margin-bottom: 1.25rem;
}
.breadcrumb {
margin-bottom: 0.75rem;
}
.breadcrumb-link {
display: inline-block;
color: var(--theme-texto);
text-decoration: none;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.05em;
transition: color 0.2s;
}
.breadcrumb-link:hover {
color: var(--theme-titulo);
}
.label-top {
display: block;
font-family: 'DM Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--theme-texto);
margin-bottom: 0.5rem;
}
h1 {
font-family: 'DM Serif Display', serif;
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
color: var(--theme-titulo);
}
.subtitulo {
max-width: 520px;
font-size: 0.95rem;
line-height: 1.5;
color: var(--theme-texto);
}
/* ── Masonry Grid ── */
.grid-clasificadores {
column-count: 3;
column-gap: 0.875rem;
margin-bottom: 1.5rem;
}
.card {
display: block;
break-inside: avoid;
margin-bottom: 0.875rem;
padding: 1.25rem 1.35rem;
background: var(--theme-surface);
border-radius: 6px;
text-decoration: none;
color: var(--theme-titulo);
transition: all 0.15s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
border: 1px solid var(--theme-borde);
}
.card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border-color: var(--theme-accent);
}
.card:nth-child(1),
.card:nth-child(5) {
padding: 1.5rem 1.35rem;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.6rem;
}
.card-numero {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
font-weight: 500;
color: var(--theme-accent);
letter-spacing: 0.05em;
}
.card-nombre {
font-family: 'DM Mono', monospace;
font-size: 0.6rem;
color: var(--theme-texto);
letter-spacing: 0.02em;
padding-left: 0.4rem;
border-left: 1px solid var(--theme-borde);
}
.card-titulo {
font-family: 'DM Serif Display', serif;
font-size: 1.35rem;
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.015em;
margin-bottom: 0.35rem;
color: var(--theme-titulo);
transition: color 0.15s;
}
.card:hover .card-titulo {
color: var(--theme-accent);
}
.card-desc {
font-size: 0.78rem;
color: var(--theme-texto);
line-height: 1.45;
margin-bottom: 0.5rem;
}
.card-ejemplo {
font-size: 0.68rem;
color: var(--theme-texto);
opacity: 0.7;
line-height: 1.4;
}
/* ── Footer ── */
.page-footer {
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.page-footer p {
font-size: 0.75rem;
color: var(--theme-texto);
}
.page-footer a {
color: var(--theme-texto);
text-decoration: none;
border-bottom: 1px solid var(--theme-borde);
transition: border-color 0.2s, color 0.2s;
}
.page-footer a:hover {
border-color: var(--theme-accent);
color: var(--theme-titulo);
}
.footnote {
font-family: 'DM Mono', monospace;
font-size: 0.58rem;
color: var(--theme-texto);
opacity: 0.7;
line-height: 1.5;
}
@media (max-width: 900px) {
.grid-clasificadores {
column-count: 2;
}
.clasificadores-page {
justify-content: flex-start;
padding-top: 1rem;
}
}
@media (max-width: 540px) {
.clasificadores-page {
padding: 1rem 1rem 1.5rem;
justify-content: flex-start;
}
.grid-clasificadores {
column-count: 1;
}
.card {
padding: 1rem 1.15rem;
}
.card:nth-child(1),
.card:nth-child(5) {
padding: 1rem 1.15rem;
}
.card-pregunta {
font-size: 1.1rem;
}
h1 { font-size: 1.4rem; }
}
</style>
......
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let loading = $state(true);
let searchQuery = $state('');
......@@ -8,6 +10,22 @@
let allEntidades = $state([]);
let selectedArea = $state(null);
let highlightedEntidad = $state(null);
let sidebarOpen = $state(false);
// Modo de visualización desde URL
let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
// Función para cambiar de modo preservando otros parámetros
function setMode(mode) {
const params = new URLSearchParams($page.url.searchParams);
if (mode === 'lista') {
params.delete('modo');
} else {
params.set('modo', mode);
}
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true });
}
onMount(async () => {
const { data, error } = await supabase
......@@ -95,6 +113,7 @@
selectedArea = area;
searchQuery = '';
highlightedEntidad = null;
sidebarOpen = false;
}
function goToSearchResult(item) {
......@@ -104,6 +123,7 @@
selectedArea = targetArea;
highlightedEntidad = item.entidad;
searchQuery = '';
sidebarOpen = false;
setTimeout(() => {
const element = document.getElementById(`ent-${item.entidad}`);
......@@ -144,27 +164,117 @@
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
let totalEntidades = $derived(allEntidades.length);
let totalAreas = $derived(areas.length);
</script>
<svelte:head>
<title>Institucional | 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 bg-white">
<!-- Header -->
<header class="border-b bg-slate-50">
<div class="max-w-screen-2xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span class="mx-2">/</span>
<a href="/clasificadores" class="hover:text-slate-700">Clasificadores</a>
<span class="mx-2">/</span>
<span class="text-slate-900">Institucional</span>
<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="flex items-center justify-between">
<div>
<h1 class="text-2xl font-light text-slate-900">Clasificador Institucional</h1>
<p class="text-sm text-slate-500 mt-1">{totalEntidades || '...'} entidades públicas</p>
<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 01
</p>
<h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
¿Quién gasta?
</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Este clasificador identifica a las <strong style="color: var(--theme-titulo);">instituciones del Estado</strong>
que administran y ejecutan el presupuesto público: ministerios, empresas estatales,
gobiernos locales y entidades descentralizadas.
</p>
{#if totalEntidades > 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;">{totalEntidades}</span> entidades públicas en
<span style="color: var(--theme-titulo); font-weight: 500;">{totalAreas}</span> áreas institucionales
</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;">
Área
</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);">
Subárea
</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);">
Entidad
</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: Área → Subárea → Entidad
</p>
</div>
<!-- Controles de visualización -->
<div class="mt-8">
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<!-- Toggle Vista -->
<div class="flex items-center gap-3">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Explorar como</span>
<nav class="inline-flex rounded-xl border-2 p-1 shadow-sm" style="border-color: var(--theme-titulo); background-color: var(--theme-surface);">
<a
href="?modo=lista"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'lista' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('lista'); }}
>
Lista
</a>
<a
href="?modo=mapa"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'mapa' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('mapa'); }}
>
Mapa
</a>
<a
href="?modo=comparar"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'comparar' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('comparar'); }}
>
Comparar
</a>
</nav>
</div>
<!-- Guía inline -->
<div class="hidden sm:block text-sm border-l pl-4" style="color: var(--theme-texto); border-color: var(--theme-borde);">
{#if viewMode === 'lista'}
<span>Navega por áreas y subáreas</span>
{:else if viewMode === 'mapa'}
<span>Visualización geográfica del presupuesto</span>
{:else}
<span>Compara dos entidades o periodos</span>
{/if}
</div>
</div>
</div>
</div>
......@@ -172,64 +282,113 @@
{#if loading}
<div class="flex items-center justify-center py-20">
<p class="text-slate-500">Cargando clasificador...</p>
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
</div>
{:else}
<div class="max-w-screen-2xl mx-auto flex">
{#if viewMode === 'lista'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO LISTA: Navegación jerárquica de entidades -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- 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">{selectedArea?.nombre || 'Seleccionar área'}</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="max-w-screen-xl mx-auto flex px-4">
<!-- Sidebar izquierda: Áreas -->
<aside class="w-64 flex-shrink-0 border-r bg-slate-50/50">
<div class="sticky top-0 h-screen overflow-y-auto p-4">
<!-- En móvil: overlay, en desktop: sidebar fijo -->
{#if sidebarOpen}
<div 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);">Áreas institucionales</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 entidad..."
class="w-full px-3 py-2 text-sm border border-slate-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2 focus:ring-offset-0"
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 text-slate-500 uppercase tracking-wide mb-2">
<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 hover:bg-white hover:shadow-sm transition-all"
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 text-slate-400 block">{result.desc_area}</span>
<span class="text-slate-700">{result.desc_entidad}</span>
<span class="text-xs block" style="color: var(--theme-texto);">{result.desc_area}</span>
<span>{result.desc_entidad}</span>
{#if result.sigla_entidad}
<span class="text-slate-400 text-xs ml-1">({result.sigla_entidad})</span>
<span class="text-xs ml-1" style="color: var(--theme-texto); opacity: 0.7;">({result.sigla_entidad})</span>
{/if}
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm text-slate-400 px-2">Sin resultados</p>
<p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
{/if}
</div>
</div>
{:else}
<!-- Lista de áreas -->
<nav>
<p class="text-xs text-slate-500 uppercase tracking-wide mb-3 px-2">Áreas</p>
<p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Áreas</p>
<ul class="space-y-1">
{#each areas as area}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all
{selectedArea?.codigo === area.codigo
? 'bg-blue-50 text-blue-900 font-medium border-l-2 border-blue-500'
: 'text-slate-600 hover:bg-white hover:text-slate-900'}"
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
style="{selectedArea?.codigo === area.codigo
? `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={() => selectArea(area)}
>
<span class="flex justify-between items-center">
<span class="truncate">{area.nombre}</span>
<span class="text-xs text-slate-400 ml-2 flex-shrink-0">{area.entidades.length}</span>
<span class="text-xs ml-2 flex-shrink-0" style="color: var(--theme-texto); opacity: 0.7;">{area.entidades.length}</span>
</span>
</button>
</li>
......@@ -241,53 +400,61 @@
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0">
<div class="px-8 py-6">
<main class="flex-1 min-w-0 lg:border-l" style="border-color: var(--theme-borde);">
<div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{#if selectedArea}
<!-- Título del área -->
<div class="mb-8 pb-6 border-b">
<h2 class="text-xl font-medium text-slate-900 mb-1">{selectedArea.nombre}</h2>
<p class="text-sm text-slate-500">{selectedArea.entidades.length} entidades</p>
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm mb-1" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Área {selectedArea.codigo}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
{selectedArea.nombre}
</h2>
<p class="text-sm" style="color: var(--theme-texto);">{selectedArea.entidades.length} entidades en esta área</p>
</div>
<!-- Subáreas y entidades -->
<div class="space-y-8">
<div class="space-y-10">
{#each areaSubareas as subarea}
<section id="subarea-{subarea.nombre.replace(/\s+/g, '-')}" class="scroll-mt-4">
{#if areaSubareas.length > 1 || subarea.nombre !== selectedArea.nombre}
<div class="flex items-start gap-4 mb-4">
<div class="flex items-start gap-2 sm:gap-4 mb-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-slate-900">{subarea.nombre}</h3>
<p class="text-sm text-slate-500">{subarea.entidades.length} entidades</p>
<h3 class="text-base sm:text-lg font-medium" style="color: var(--theme-titulo);">{subarea.nombre}</h3>
<p class="text-sm" style="color: var(--theme-texto);">{subarea.entidades.length} entidades</p>
</div>
</div>
{/if}
<!-- Entidades -->
<div class="border rounded-lg divide-y">
<div class="ml-0 sm:ml-4 lg:ml-8 space-y-2 border-l-2 pl-4 sm:pl-6 lg:pl-8" style="border-color: var(--theme-borde);">
{#each subarea.entidades as entidad}
<div
id="ent-{entidad.codigo}"
class="scroll-mt-4 {highlightedEntidad === entidad.codigo ? 'ring-2 ring-blue-200' : ''}"
class="scroll-mt-4"
style="{highlightedEntidad === entidad.codigo ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem;` : ''}"
>
<a
href="/entidad/{entidad.codigo}"
class="flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition-colors"
class="block px-4 py-3 rounded-lg border transition-all group"
style="background-color: var(--theme-surface); border-color: var(--theme-borde);"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-slate-900">{entidad.nombre}</span>
{#if entidad.sigla}
<span class="text-sm text-slate-400">({entidad.sigla})</span>
{/if}
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-xs" style="color: var(--theme-texto);">{entidad.codigo}</span>
<span class="font-medium" style="color: var(--theme-titulo);">{entidad.nombre}</span>
{#if entidad.sigla}
<span class="text-sm" style="color: var(--theme-texto); opacity: 0.7;">({entidad.sigla})</span>
{/if}
</div>
<p class="text-xs mt-1" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
{formatGestiones(entidad.gestiones)}
</p>
</div>
<p class="text-xs text-slate-400 mt-0.5">
Código: {entidad.codigo} · {formatGestiones(entidad.gestiones)}
</p>
<svg class="w-4 h-4 flex-shrink-0 mt-1 transition-colors group-hover:text-[var(--theme-accent)]" style="color: var(--theme-texto); opacity: 0.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>
</div>
<svg class="w-4 h-4 text-slate-300 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
{/each}
......@@ -301,29 +468,147 @@
<!-- 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">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-3">En esta página</p>
<div class="sticky top-0 h-screen overflow-y-auto py-8 pl-6 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="color: var(--theme-texto);">En esta página</p>
{#if selectedArea && !isSearching}
<nav class="space-y-2">
<nav class="space-y-3">
{#each areaSubareas as subarea}
{#if areaSubareas.length > 1 || subarea.nombre !== selectedArea.nombre}
<a
href="#subarea-{subarea.nombre.replace(/\s+/g, '-')}"
class="block text-sm text-slate-600 hover:text-blue-600 truncate"
class="block text-sm leading-snug transition-colors hover:opacity-80"
style="color: var(--theme-titulo);"
title={subarea.nombre}
>
{subarea.nombre}
<span class="text-xs text-slate-400 ml-1">({subarea.entidades.length})</span>
<span class="text-xs ml-1" style="color: var(--theme-texto);">({subarea.entidades.length})</span>
</a>
{/if}
{/each}
{#if areaSubareas.length === 1 && areaSubareas[0].nombre === selectedArea.nombre}
<p class="text-sm text-slate-400">{selectedArea.entidades.length} entidades</p>
<p class="text-sm" style="color: var(--theme-texto);">{selectedArea.entidades.length} entidades</p>
{/if}
</nav>
{/if}
</div>
</aside>
</div>
{:else if viewMode === 'mapa'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO MAPA: Visualización geográfica del presupuesto -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-8">
<div class="border-2 border-dashed rounded-2xl min-h-[500px] flex flex-col items-center justify-center p-8" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<svg class="w-16 h-16 mb-4" style="color: var(--theme-texto); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<h3 class="text-lg font-medium mb-2" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
Mapa de Bolivia
</h3>
<p class="text-center max-w-md mb-4" style="color: var(--theme-texto);">
Visualización geográfica del presupuesto por departamento y municipio.
El color indica el monto ejecutado por cada entidad territorial.
</p>
<div class="flex flex-wrap gap-2 justify-center text-xs" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<span class="px-2 py-1 rounded border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">9 departamentos</span>
<span class="px-2 py-1 rounded border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">340 municipios</span>
<span class="px-2 py-1 rounded border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">TopoJSON + D3.js</span>
</div>
</div>
<div class="mt-6 p-4 rounded-lg border" style="background-color: color-mix(in srgb, var(--theme-accent) 10%, transparent); border-color: color-mix(in srgb, var(--theme-accent) 30%, transparent);">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 shrink-0 mt-0.5" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="text-sm font-medium" style="color: var(--theme-titulo);">Próximamente</p>
<p class="text-sm mt-1" style="color: var(--theme-texto);">
Esta visualización mostrará el presupuesto ejecutado en cada territorio de Bolivia.
Podrás explorar por departamento, provincia y municipio.
</p>
</div>
</div>
</div>
</div>
{:else if viewMode === 'comparar'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO COMPARAR: Dos entidades lado a lado -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-8">
<div class="flex flex-col lg:flex-row gap-6 lg:gap-8 mb-8">
<!-- Lado A -->
<div class="flex-1">
<div class="flex items-center gap-3 mb-4">
<span class="w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">A</span>
<span class="font-medium" style="color: var(--theme-titulo);">Selecciona una entidad</span>
</div>
<div class="border-2 border-dashed rounded-xl min-h-[350px] flex flex-col items-center justify-center p-6" style="background-color: color-mix(in srgb, var(--theme-accent) 5%, transparent); border-color: color-mix(in srgb, var(--theme-accent) 30%, transparent);">
<svg class="w-12 h-12 mb-3" style="color: var(--theme-accent); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p class="font-medium" style="color: var(--theme-accent);">Entidad A</p>
<p class="text-sm mt-1" style="color: var(--theme-texto);">Evolución presupuestaria</p>
</div>
</div>
<!-- Separador -->
<div class="flex lg:flex-col items-center justify-center gap-2">
<span class="font-medium text-sm" style="color: var(--theme-texto);">vs</span>
</div>
<!-- Lado B -->
<div class="flex-1">
<div class="flex items-center gap-3 mb-4">
<span class="w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm" style="background-color: color-mix(in srgb, #8b5cf6 20%, transparent); color: #8b5cf6;">B</span>
<span class="font-medium" style="color: var(--theme-titulo);">Selecciona otra entidad</span>
</div>
<div class="border-2 border-dashed rounded-xl min-h-[350px] flex flex-col items-center justify-center p-6" style="background-color: color-mix(in srgb, #8b5cf6 5%, transparent); border-color: color-mix(in srgb, #8b5cf6 30%, transparent);">
<svg class="w-12 h-12 mb-3" style="color: #8b5cf6; opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p class="font-medium" style="color: #8b5cf6;">Entidad B</p>
<p class="text-sm mt-1" style="color: var(--theme-texto);">Evolución presupuestaria</p>
</div>
</div>
</div>
<div class="border rounded-xl p-6" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<h3 class="text-sm font-medium uppercase tracking-wide mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-titulo);">
Comparación
</h3>
<div class="grid sm:grid-cols-3 gap-4 text-center">
<div class="p-4 rounded-lg border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--theme-texto);">Presupuesto total</p>
<p class="text-sm italic" style="color: var(--theme-texto);">Selecciona entidades</p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--theme-texto);">Diferencia</p>
<p class="text-sm italic" style="color: var(--theme-texto);">Selecciona entidades</p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--theme-texto);">Tendencia</p>
<p class="text-sm italic" style="color: var(--theme-texto);">Selecciona entidades</p>
</div>
</div>
</div>
</div>
{/if}
{/if}
</div>
<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;
}
}
</style>
......
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as d3 from 'd3';
let loading = $state(true);
let searchQuery = $state('');
......@@ -9,8 +12,506 @@
let selectedGrupo = $state(null);
let selectedItem = $state(null);
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
// Modo de visualización desde URL
let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
// Función para cambiar de modo preservando otros parámetros
function setMode(mode) {
const params = new URLSearchParams($page.url.searchParams);
if (mode === 'lista') {
params.delete('modo');
} else {
params.set('modo', mode);
}
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true });
}
let availableYears = $state([]);
let selectedYear = $state(null);
let entities = $state([]);
let selectedEntity = $state(null); // null = Todo el Estado
let entitySearchQuery = $state('');
let entityDropdownOpen = $state(false);
// Estado para modo comparar
let compareA = $state({ year: null, entity: null });
let compareB = $state({ year: null, entity: null });
let compareADropdownOpen = $state(false);
let compareBDropdownOpen = $state(false);
let compareASearchQuery = $state('');
let compareBSearchQuery = $state('');
// ══════════════════════════════════════════════════════════════
// ESTADO PARA TREEMAP
// ══════════════════════════════════════════════════════════════
let treemapData = $state([]);
let treemapRoot = $state(null);
let treemapNodes = $state([]);
let currentTreemapNode = $state(null); // Nodo actual para drill-down
let treemapBreadcrumb = $state([]); // Path de navegación
let treemapWidth = $state(0);
let treemapHeight = $state(500);
let treemapContainer = $state(null);
let hoveredNode = $state(null);
// Selector de nivel para vista aplanada
let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida'
const NIVEL_OPTIONS = [
{ value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegación drill-down' },
{ value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' },
{ value: 'partida', label: 'Partidas', desc: '~100 categorías' },
{ value: 'subpartida', label: 'Subpartidas', desc: 'Máximo detalle' }
];
// Colores por grupo (primer dígito del código)
const GRUPO_COLORS = {
'1': '#4E79A7', // Servicios Personales - azul
'2': '#F28E2B', // Servicios No Personales - naranja
'3': '#E15759', // Materiales y Suministros - rojo
'4': '#76B7B2', // Activos Reales - teal
'5': '#59A14F', // Activos Financieros - verde
'6': '#EDC948', // Servicio de la Deuda - amarillo
'7': '#B07AA1', // Transferencias - púrpura
'8': '#FF9DA7', // Impuestos, Regalías - rosa
'9': '#9C755F', // Otros Gastos - marrón
};
// Función para obtener color según el grupo
function getNodeColor(objeto, opacity = 1) {
const grupoDigit = String(objeto).charAt(0);
const baseColor = GRUPO_COLORS[grupoDigit] || '#888888';
if (opacity === 1) return baseColor;
return d3.color(baseColor).copy({opacity}).formatRgb();
}
// Función para determinar si el texto debe ser claro u oscuro según el fondo
function getTextColor(objeto) {
const grupoDigit = String(objeto).charAt(0);
const baseColor = GRUPO_COLORS[grupoDigit] || '#888888';
const color = d3.color(baseColor);
// Calcular luminosidad relativa (fórmula WCAG)
const r = color.r / 255;
const g = color.g / 255;
const b = color.b / 255;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
// Si el fondo es claro (amarillo, verde claro), usar texto oscuro
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
function getTextColorSecondary(objeto) {
const grupoDigit = String(objeto).charAt(0);
const baseColor = GRUPO_COLORS[grupoDigit] || '#888888';
const color = d3.color(baseColor);
const r = color.r / 255;
const g = color.g / 255;
const b = color.b / 255;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
return luminance > 0.5 ? 'rgba(0,0,0,0.65)' : 'rgba(255,255,255,0.75)';
}
// Formatear números en bolivianos
function formatMoney(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
return `Bs ${value.toFixed(0)}`;
}
// Formatear per cápita (población ~10M)
const POBLACION = 10000000;
function formatPerCapita(value) {
const perCapita = value / POBLACION;
if (perCapita >= 1000) return `Bs ${(perCapita / 1000).toFixed(1)}K/hab`;
if (perCapita >= 1) return `Bs ${perCapita.toFixed(0)}/hab`;
return `Bs ${perCapita.toFixed(2)}/hab`;
}
// Cargar datos del treemap
async function loadTreemapData() {
// Limpiar estado antes de cargar nuevos datos
treemapNodes = [];
try {
const response = await fetch('/treemap_objeto_estado.csv');
const csvText = await response.text();
const parsed = d3.csvParse(csvText, d => ({
gestion: +d.gestion,
nivel: d.nivel,
objeto: d.objeto,
desc_objeto: d.desc_objeto === 'NA' ? null : d.desc_objeto,
parent: d.parent === 'NA' ? null : d.parent,
devengado: +d.devengado || 0
}));
// Filtrar por año y eliminar subpartidas duplicadas (las que tienen desc_objeto = null)
// Estas son partidas sin desglose que aparecen duplicadas
const filtered = parsed.filter(d =>
d.gestion === selectedYear &&
d.devengado > 0 &&
!(d.nivel === 'subpartida' && d.desc_objeto === null)
);
treemapData = filtered;
console.log('Treemap data loaded:', treemapData.length, 'items');
buildTreemapHierarchy();
} catch (err) {
console.error('Error loading treemap data:', err);
}
}
// Construir jerarquía D3
function buildTreemapHierarchy() {
if (!treemapData.length) {
console.log('No treemap data to build hierarchy');
return;
}
// Si es vista aplanada por nivel, usar lógica diferente
if (treemapViewLevel !== 'jerarquico') {
buildFlatTreemap();
return;
}
// === MODO JERÁRQUICO (drill-down) ===
const stratify = d3.stratify()
.id(d => d.objeto)
.parentId(d => d.parent);
// Agregar nodo raíz virtual
const dataWithRoot = [
{ objeto: 'root', parent: null, desc_objeto: 'Total Presupuesto', devengado: 0, nivel: 'root' },
...treemapData.map(d => ({
...d,
parent: d.parent || 'root'
}))
];
// Verificar IDs duplicados
const ids = dataWithRoot.map(d => d.objeto);
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
if (duplicates.length > 0) {
console.error('Duplicate IDs found:', [...new Set(duplicates)]);
}
try {
const root = stratify(dataWithRoot);
// Sumar valores: usar devengado de nodos hoja (sin hijos)
root.sum(d => d.devengado)
.sort((a, b) => b.value - a.value);
// Eliminar doble conteo: solo contar hojas reales
root.eachAfter(node => {
if (node.children && node.children.length > 0) {
node.value = node.children.reduce((sum, child) => sum + child.value, 0);
}
});
treemapRoot = root;
currentTreemapNode = root;
treemapBreadcrumb = [{ id: 'root', name: 'Total' }];
calculateTreemapLayout();
} catch (err) {
console.error('Error building hierarchy:', err);
}
}
// Construir treemap aplanado por nivel
function buildFlatTreemap() {
// Filtrar solo items del nivel seleccionado
const levelItems = treemapData.filter(d => d.nivel === treemapViewLevel);
if (!levelItems.length) {
console.log('No items for level:', treemapViewLevel);
treemapNodes = [];
return;
}
// Agrupar por grupo (primer dígito) para crear estructura anidada
const gruposMap = new Map();
// Primero obtener info de los grupos
const gruposData = treemapData.filter(d => d.nivel === 'grupo');
levelItems.forEach(item => {
const grupoCode = String(item.objeto).charAt(0);
if (!gruposMap.has(grupoCode)) {
const grupoInfo = gruposData.find(g => g.objeto === grupoCode + '0000') || { desc_objeto: `Grupo ${grupoCode}` };
gruposMap.set(grupoCode, {
id: `grupo_${grupoCode}`,
name: grupoInfo.desc_objeto,
grupoCode,
children: []
});
}
gruposMap.get(grupoCode).children.push({
id: item.objeto,
name: item.desc_objeto || item.objeto,
value: item.devengado,
data: item,
grupoCode
});
});
// Crear jerarquía con grupos como contenedores
const hierarchyData = {
id: 'root',
name: 'Total',
children: Array.from(gruposMap.values()).map(grupo => ({
...grupo,
children: grupo.children.sort((a, b) => b.value - a.value)
}))
};
// Ordenar grupos por valor total
hierarchyData.children.forEach(grupo => {
grupo.totalValue = grupo.children.reduce((sum, child) => sum + child.value, 0);
});
hierarchyData.children.sort((a, b) => b.totalValue - a.totalValue);
const root = d3.hierarchy(hierarchyData);
// Solo sumar valores de hojas (items), no de grupos
root.sum(d => d.children ? 0 : (d.value || 0));
treemapRoot = root;
currentTreemapNode = root;
treemapBreadcrumb = [{ id: 'root', name: `Todos los ${NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || 'items'}` }];
calculateFlatTreemapLayout();
}
// Layout especial para vista aplanada (muestra grupos como secciones)
function calculateFlatTreemapLayout() {
if (!currentTreemapNode || !currentTreemapNode.children) {
treemapNodes = [];
return;
}
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(4)
.paddingTop(22) // Espacio para el título del grupo
.paddingInner(2)
.round(true);
treemap(currentTreemapNode);
// Extraer nodos: grupos (para títulos) e items individuales
const nodes = [];
currentTreemapNode.children.forEach(grupoNode => {
// Agregar el grupo como "contenedor" con título
nodes.push({
type: 'grupo',
id: grupoNode.data.id,
name: grupoNode.data.name,
grupoCode: grupoNode.data.grupoCode,
x0: grupoNode.x0,
y0: grupoNode.y0,
x1: grupoNode.x1,
y1: grupoNode.y1,
value: grupoNode.value
});
// Agregar los items del grupo
if (grupoNode.children) {
grupoNode.children.forEach(itemNode => {
nodes.push({
type: 'item',
id: itemNode.data.id,
name: itemNode.data.name,
grupoCode: grupoNode.data.grupoCode,
x0: itemNode.x0,
y0: itemNode.y0,
x1: itemNode.x1,
y1: itemNode.y1,
value: itemNode.value,
data: itemNode.data.data || itemNode.data
});
});
}
});
treemapNodes = nodes;
console.log('Flat layout calculated:', nodes.length, 'nodes');
}
// Calcular layout del treemap (modo jerárquico)
function calculateTreemapLayout() {
if (!currentTreemapNode || !currentTreemapNode.children || currentTreemapNode.children.length === 0) {
treemapNodes = [];
return;
}
if (treemapWidth <= 0 || treemapHeight <= 0) {
return;
}
try {
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.padding(3)
.paddingTop(20)
.round(true);
// Crear una nueva jerarquía temporal para el layout
// Esto asegura que el nodo tenga coordenadas válidas
const tempHierarchy = d3.hierarchy({
id: currentTreemapNode.id,
data: currentTreemapNode.data,
children: currentTreemapNode.children.map(child => ({
id: child.id,
data: child.data,
value: child.value,
hasChildren: child.children && child.children.length > 0,
_ref: child // Referencia al nodo original
}))
});
// Sumar valores (solo hojas)
tempHierarchy.sum(d => d.children ? 0 : (d.value || 0));
tempHierarchy.sort((a, b) => b.value - a.value);
// Aplicar layout
treemap(tempHierarchy);
// Extraer los nodos hijos con coordenadas calculadas
treemapNodes = tempHierarchy.children.map(child => ({
x0: child.x0,
y0: child.y0,
x1: child.x1,
y1: child.y1,
id: child.data.id,
data: child.data.data,
value: child.value,
_originalNode: child.data._ref // Usar la referencia al nodo original
}));
} catch (err) {
console.error('Error calculating layout:', err);
treemapNodes = [];
}
}
// Navegar hacia abajo (drill-down)
function drillDown(node) {
const originalNode = node._originalNode || node;
if (!originalNode.children || originalNode.children.length === 0) return;
currentTreemapNode = originalNode;
treemapBreadcrumb = [...treemapBreadcrumb, {
id: originalNode.id,
name: originalNode.data.desc_objeto || originalNode.id
}];
calculateTreemapLayout();
}
// Navegar hacia arriba
function drillUp(index) {
if (index === 0) {
currentTreemapNode = treemapRoot;
treemapBreadcrumb = [{ id: 'root', name: 'Total' }];
} else {
// Encontrar el nodo en el breadcrumb
const targetId = treemapBreadcrumb[index].id;
let node = treemapRoot;
for (let i = 1; i <= index; i++) {
const nextId = treemapBreadcrumb[i].id;
node = node.children?.find(c => c.id === nextId) || node;
}
currentTreemapNode = node;
treemapBreadcrumb = treemapBreadcrumb.slice(0, index + 1);
}
calculateTreemapLayout();
}
// Reactivo: recargar datos cuando cambia el año o nivel
// Usar variable derivada para evitar loops en effects
let treemapLoadTrigger = $derived(`${selectedYear}-${treemapViewLevel}-${viewMode}`);
let lastTrigger = '';
$effect(() => {
const trigger = treemapLoadTrigger;
if (viewMode === 'mapa' && selectedYear && trigger !== lastTrigger) {
lastTrigger = trigger;
loadTreemapData();
}
});
// Función para recalcular layout según el modo actual
function recalculateCurrentLayout() {
try {
if (treemapViewLevel === 'jerarquico') {
if (currentTreemapNode && currentTreemapNode.children) {
calculateTreemapLayout();
}
} else {
if (currentTreemapNode && currentTreemapNode.children) {
calculateFlatTreemapLayout();
}
}
} catch (err) {
console.error('Error recalculating layout:', err);
}
}
// Manejar resize del contenedor (solo una vez al montar)
let resizeObserver = null;
let scrollHandler = null;
$effect(() => {
if (treemapContainer && viewMode === 'mapa') {
// Función para actualizar dimensiones y recalcular layout
const updateDimensions = () => {
const rect = treemapContainer.getBoundingClientRect();
if (rect.width > 0) {
const newWidth = Math.floor(rect.width);
// Calcular altura disponible para que quepa en viewport
const viewportHeight = window.innerHeight;
const paddingBottom = 24;
const effectiveTop = Math.max(rect.top, 0);
const availableHeight = viewportHeight - effectiveTop - paddingBottom;
const newHeight = Math.max(250, Math.floor(availableHeight));
if (newWidth !== treemapWidth || newHeight !== treemapHeight) {
treemapWidth = newWidth;
treemapHeight = newHeight;
recalculateCurrentLayout();
}
}
};
// Calcular dimensiones iniciales con pequeño delay para asegurar render
setTimeout(updateDimensions, 50);
// Usar ResizeObserver para cambios de tamaño
resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
resizeObserver.observe(treemapContainer);
// También escuchar scroll para recalcular cuando el usuario hace scroll
scrollHandler = () => {
updateDimensions();
};
window.addEventListener('scroll', scrollHandler, { passive: true });
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler);
}
};
}
});
onMount(async () => {
// Cargar clasificador de objetos
const { data, error } = await supabase
.schema('ppto')
.from('clas_objetos')
......@@ -37,9 +538,148 @@
}
}
// Cargar años disponibles desde el CSV del treemap
try {
const response = await fetch('/treemap_objeto_estado.csv');
const csvText = await response.text();
const parsed = d3.csvParse(csvText);
const uniqueYears = [...new Set(parsed.map(d => +d.gestion))].sort((a, b) => b - a);
availableYears = uniqueYears;
selectedYear = availableYears[0]; // Año más reciente por defecto
} catch (err) {
// Fallback si falla la carga
availableYears = [2025];
selectedYear = 2025;
}
// Cargar entidades para el selector
const { data: entidadesData } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('entidad, desc_entidad, sigla_entidad, desc_area')
.order('desc_entidad');
if (entidadesData) {
entities = entidadesData;
}
loading = false;
});
// Filtrar entidades por búsqueda
let filteredEntities = $derived(() => {
if (!entitySearchQuery || entitySearchQuery.length < 2) {
return entities.slice(0, 20); // Mostrar primeras 20 si no hay búsqueda
}
const q = entitySearchQuery.toLowerCase();
return entities
.filter(e =>
e.desc_entidad?.toLowerCase().includes(q) ||
e.sigla_entidad?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, 20);
});
function selectEntity(entity) {
selectedEntity = entity;
entityDropdownOpen = false;
entitySearchQuery = '';
}
function clearEntity() {
selectedEntity = null;
entityDropdownOpen = false;
entitySearchQuery = '';
}
// Filtrar entidades para comparación A
let filteredEntitiesA = $derived(() => {
if (!compareASearchQuery || compareASearchQuery.length < 2) {
return entities.slice(0, 20);
}
const q = compareASearchQuery.toLowerCase();
return entities
.filter(e =>
e.desc_entidad?.toLowerCase().includes(q) ||
e.sigla_entidad?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, 20);
});
// Filtrar entidades para comparación B
let filteredEntitiesB = $derived(() => {
if (!compareBSearchQuery || compareBSearchQuery.length < 2) {
return entities.slice(0, 20);
}
const q = compareBSearchQuery.toLowerCase();
return entities
.filter(e =>
e.desc_entidad?.toLowerCase().includes(q) ||
e.sigla_entidad?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, 20);
});
// Inicializar modo comparar con valores inteligentes
function initCompareMode() {
// Lado A: usar selección actual de Mapa, o defaults
compareA = {
year: selectedYear || availableYears[0],
entity: selectedEntity
};
// Lado B: misma entidad, año anterior
const yearIndex = availableYears.indexOf(compareA.year);
compareB = {
year: availableYears[yearIndex + 1] || availableYears[0],
entity: selectedEntity
};
}
function selectCompareEntity(side, entity) {
if (side === 'A') {
compareA = { ...compareA, entity };
compareADropdownOpen = false;
compareASearchQuery = '';
} else {
compareB = { ...compareB, entity };
compareBDropdownOpen = false;
compareBSearchQuery = '';
}
}
function clearCompareEntity(side) {
if (side === 'A') {
compareA = { ...compareA, entity: null };
compareADropdownOpen = false;
compareASearchQuery = '';
} else {
compareB = { ...compareB, entity: null };
compareBDropdownOpen = false;
compareBSearchQuery = '';
}
}
function swapCompareSides() {
const temp = compareA;
compareA = compareB;
compareB = temp;
}
function handleClickOutsideEntity(e) {
if (entityDropdownOpen && !e.target.closest('.entity-dropdown-container')) {
entityDropdownOpen = false;
}
if (compareADropdownOpen && !e.target.closest('.compare-a-dropdown')) {
compareADropdownOpen = false;
}
if (compareBDropdownOpen && !e.target.closest('.compare-b-dropdown')) {
compareBDropdownOpen = false;
}
}
function getItemsForGrupo(grupoCode) {
if (!grupoCode) return { subgrupos: [] };
......@@ -134,6 +774,7 @@
selectedItem = null;
searchQuery = '';
highlightedItem = null;
sidebarOpen = false; // Cerrar sidebar en móvil al seleccionar
}
function openDetail(item) {
......@@ -173,85 +814,389 @@
let grupoContent = $derived(selectedGrupo ? getItemsForGrupo(selectedGrupo.objeto) : { subgrupos: [] });
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
// Contadores
let totalItems = $derived(allItems.length);
let countByNivel = $derived({
grupos: allItems.filter(i => i.nivel === 'grupo').length,
subgrupos: allItems.filter(i => i.nivel === 'subgrupo').length,
partidas: allItems.filter(i => i.nivel === 'partida').length,
subpartidas: allItems.filter(i => i.nivel === 'subpartida').length
});
</script>
<svelte:head>
<title>Objeto del Gasto | 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 bg-white">
<!-- Header -->
<header class="border-b bg-slate-50">
<div class="max-w-screen-2xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span class="mx-2">/</span>
<a href="/clasificadores" class="hover:text-slate-700">Clasificadores</a>
<span class="mx-2">/</span>
<span class="text-slate-900">Objeto del Gasto</span>
<svelte:window onclick={handleClickOutsideEntity} />
<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="flex items-center justify-between">
<h1 class="text-2xl font-light text-slate-900">Clasificador por Objeto del Gasto</h1>
<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 03
</p>
<h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
¿En qué se gasta?
</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Ordena el gasto según <strong style="color: var(--theme-titulo);">qué se compra o paga</strong>:
sueldos, alquileres, combustible, medicamentos, construcciones, deudas, transferencias.
Es la forma más directa de entender en qué se usa el dinero 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> categorías de gasto:
{countByNivel.grupos} grupos, {countByNivel.subgrupos} subgrupos, {countByNivel.partidas} partidas, {countByNivel.subpartidas} subpartidas
</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);">
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);">
Partida
</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);">
Subpartida
</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 → Subgrupo → Partida → Subpartida
</p>
</div>
<!-- Controles de visualización (fuera del max-w-3xl para usar todo el ancho) -->
<div class="mt-8">
<!-- Fila principal: Toggle + Guía + Filtros -->
<div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
<!-- Toggle + Guía inline -->
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<!-- Toggle Vista -->
<div class="flex items-center gap-3">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Explorar como</span>
<nav class="inline-flex rounded-xl border-2 p-1 shadow-sm" style="border-color: var(--theme-titulo); background-color: var(--theme-surface);">
<a
href="?modo=lista"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'lista' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('lista'); }}
>
Lista
</a>
<a
href="?modo=mapa"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'mapa' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('mapa'); }}
>
Mapa
</a>
<a
href="?modo=comparar"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'comparar' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('comparar'); initCompareMode(); }}
>
Comparar
</a>
</nav>
</div>
<!-- Guía inline (visible en sm+) -->
<div class="hidden sm:block text-sm border-l pl-4" style="color: var(--theme-texto); border-color: var(--theme-borde);">
{#if viewMode === 'lista'}
<span class="flex items-center gap-1.5">
Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">
<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>
</span>
para ver el presupuesto histórico ·
<span class="underline decoration-dotted font-medium" style="color: var(--theme-accent);">N var.</span>
= número de cambios en la descripción oficial
</span>
{:else if viewMode === 'mapa'}
<span class="flex items-center gap-1.5">
{#if treemapViewLevel === 'jerarquico'}
Tamaño = peso en el presupuesto · Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">+</span>
para ver desglose
{:else}
Vista aplanada · Colores agrupan por categoría principal
{/if}
</span>
{:else}
<span>Selecciona año y entidad para cada lado</span>
{/if}
</div>
</div>
<!-- Filtros (solo en modo mapa) -->
{#if viewMode === 'mapa'}
<div class="flex flex-wrap items-center gap-3 flex-shrink-0">
<!-- Selector de nivel -->
<div class="flex items-center gap-2">
<label for="level-select" class="text-xs uppercase tracking-wide" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Vista</label>
<select
id="level-select"
bind:value={treemapViewLevel}
class="px-3 py-1.5 text-sm rounded-lg focus:outline-none focus:ring-2 cursor-pointer"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
>
{#each NIVEL_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex items-center gap-2">
<label for="year-select" class="text-xs uppercase tracking-wide" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Año</label>
<select
id="year-select"
bind:value={selectedYear}
class="px-3 py-1.5 text-sm rounded-lg focus:outline-none focus:ring-2 cursor-pointer"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
>
{#each availableYears as year}
<option value={year}>{year}</option>
{/each}
</select>
</div>
<div class="flex items-center gap-2 entity-dropdown-container">
<label class="text-xs uppercase tracking-wide" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Entidad</label>
<div class="relative">
<button
class="px-3 py-1.5 text-sm rounded-lg focus:outline-none focus:ring-2 min-w-[180px] text-left flex items-center justify-between gap-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
onclick={() => entityDropdownOpen = !entityDropdownOpen}
>
<span class="truncate">
{#if selectedEntity}
{selectedEntity.sigla_entidad || selectedEntity.desc_entidad?.slice(0, 25)}
{:else}
Todo el Estado
{/if}
</span>
<svg class="w-4 h-4 shrink-0" style="color: var(--theme-texto);" 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>
{#if entityDropdownOpen}
<div class="absolute top-full left-0 mt-1 w-80 rounded-lg shadow-lg z-50 max-h-72 overflow-hidden" style="background-color: var(--theme-surface); border: 1px solid var(--theme-borde);">
<div class="p-2 border-b" style="border-color: var(--theme-borde);">
<input
type="text"
bind:value={entitySearchQuery}
placeholder="Buscar entidad..."
class="w-full px-3 py-1.5 text-sm rounded focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-fill); color: var(--theme-titulo);"
/>
</div>
<button
class="w-full text-left px-3 py-2 text-sm border-b"
style="{!selectedEntity ? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo);` : `color: var(--theme-titulo);`} border-color: var(--theme-borde);"
onclick={() => clearEntity()}
>
<span class="font-medium">Todo el Estado</span>
<span class="text-xs ml-2" style="color: var(--theme-texto);">(agregado nacional)</span>
</button>
<div class="overflow-y-auto max-h-48">
{#each filteredEntities() as entity}
<button
class="w-full text-left px-3 py-2 text-sm"
style="{selectedEntity?.entidad === entity.entidad ? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);` : ``} color: var(--theme-titulo);"
onclick={() => selectEntity(entity)}
>
<div class="flex items-center gap-2">
<span class="font-mono text-xs" style="color: var(--theme-texto);">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
{#if entity.sigla_entidad}
<span class="text-xs" style="color: var(--theme-texto);">({entity.sigla_entidad})</span>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Guía móvil (solo visible en móvil, no en comparar) -->
{#if viewMode !== 'comparar'}
<div class="sm:hidden mt-3 pt-3 border-t text-sm" style="border-color: var(--theme-borde); color: var(--theme-texto);">
{#if viewMode === 'lista'}
<p class="flex flex-wrap items-center gap-1.5">
Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">
<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>
</span>
para ver el presupuesto histórico.
<span class="underline decoration-dotted font-medium" style="color: var(--theme-accent);">N var.</span>
= número de cambios en la descripción oficial.
</p>
{:else if viewMode === 'mapa'}
<p class="flex flex-wrap items-center gap-1.5">
{#if treemapViewLevel === 'jerarquico'}
Tamaño = peso · Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">+</span>
para desglose.
{:else}
Colores agrupan por categoría.
{/if}
</p>
{:else}
<p>Selecciona año y entidad para cada lado.</p>
{/if}
</div>
{/if}
</div>
</div>
</header>
{#if loading}
<div class="flex items-center justify-center py-20">
<p class="text-slate-500">Cargando clasificador...</p>
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
</div>
{:else}
<div class="max-w-screen-2xl mx-auto flex">
{#if viewMode === 'lista'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO LISTA: Navegación jerárquica de categorías de gasto -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- 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?.desc_objeto || '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="max-w-screen-xl mx-auto flex px-4">
<!-- Sidebar izquierda: Grupos -->
<aside class="w-64 flex-shrink-0 border-r bg-slate-50/50">
<div class="sticky top-0 h-screen overflow-y-auto p-4">
<!-- En móvil: overlay, en desktop: sidebar fijo -->
{#if sidebarOpen}
<div 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 gasto</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 border border-slate-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
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 text-slate-500 uppercase tracking-wide mb-2">
<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 hover:bg-white hover:shadow-sm transition-all"
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 text-slate-400 block">{getNivelLabel(result.nivel)}</span>
<span class="font-mono text-xs text-blue-600">{result.objeto}</span>
<span class="text-slate-700 ml-1">{result.desc_objeto}</span>
<span class="text-xs block" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.objeto}</span>
<span class="ml-1">{result.desc_objeto}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm text-slate-400 px-2">Sin resultados</p>
<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 text-slate-500 uppercase tracking-wide mb-3 px-2">Grupos</p>
<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
{selectedGrupo?.objeto === grupo.objeto
? 'bg-blue-50 text-blue-900 font-medium border-l-2 border-blue-500'
: 'text-slate-600 hover:bg-white hover:text-slate-900'}"
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
style="{selectedGrupo?.objeto === grupo.objeto
? `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 text-slate-400 block">{grupo.objeto}</span>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">{grupo.objeto}</span>
{grupo.desc_objeto}
</button>
</li>
......@@ -263,17 +1208,18 @@
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0">
<div class="px-8 py-6">
<main class="flex-1 min-w-0 lg:border-l" style="border-color: var(--theme-borde);">
<div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{#if selectedGrupo}
<!-- Título del grupo -->
<div class="mb-8 pb-6 border-b">
<p class="text-sm font-mono text-slate-400 mb-1">{selectedGrupo.objeto}</p>
<h2 class="text-xl font-medium text-slate-900 mb-2 flex items-center gap-3">
<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);">{selectedGrupo.objeto}</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_objeto}
<a
href="/objeto/{selectedGrupo.objeto}"
class="text-slate-300 hover:text-blue-500 transition-colors"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver detalle"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......@@ -284,7 +1230,7 @@
{#if selectedGrupo.descripciones}
{@const grupoDescs = parseDescripciones(selectedGrupo.descripciones)}
{#if grupoDescs.length > 0}
<p class="text-slate-600 leading-relaxed">{grupoDescs[0].descripcion}</p>
<p class="leading-relaxed" style="color: var(--theme-texto);">{grupoDescs[0].descripcion}</p>
{#if selectedGrupo.n_variaciones > 1}
<button
class="text-sm text-orange-500 hover:text-orange-700 mt-2 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
......@@ -298,17 +1244,18 @@
</div>
<!-- Subgrupos -->
<div class="space-y-8">
<div class="space-y-12">
{#each grupoContent.subgrupos as subgrupo}
{@const subgrupoDescs = parseDescripciones(subgrupo.descripciones)}
<section
id="sg-{subgrupo.objeto}"
class="scroll-mt-4 {highlightedItem === subgrupo.objeto ? 'ring-2 ring-blue-200 rounded-lg' : ''}"
class="scroll-mt-4"
style="{highlightedItem === subgrupo.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem;` : ''}"
>
<div class="flex items-start gap-4 mb-3">
<span class="font-mono text-sm text-slate-400 pt-1">{subgrupo.objeto}</span>
<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.objeto}</span>
<div class="flex-1">
<h3 class="text-lg font-medium text-slate-900 flex items-center gap-2">
<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">{subgrupo.desc_objeto}</span>
{#if subgrupo.n_variaciones > 1}
<button
......@@ -321,7 +1268,8 @@
{/if}
<a
href="/objeto/{subgrupo.objeto}"
class="text-slate-300 hover:text-blue-500 transition-colors"
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">
......@@ -330,24 +1278,25 @@
</a>
</h3>
{#if subgrupoDescs.length > 0}
<p class="text-sm text-slate-600 mt-1 leading-relaxed">{subgrupoDescs[0].descripcion}</p>
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{subgrupoDescs[0].descripcion}</p>
{/if}
</div>
</div>
<!-- Partidas -->
{#if subgrupo.partidas?.length > 0}
<div class="ml-16 space-y-4 border-l-2 border-slate-100 pl-6">
<div class="ml-4 sm:ml-8 lg:ml-16 space-y-5 border-l pl-4 sm:pl-6 lg:pl-8" style="border-color: var(--theme-borde);">
{#each subgrupo.partidas as partida}
{@const partidaDescs = parseDescripciones(partida.descripciones)}
<div
id="p-{partida.objeto}"
class="scroll-mt-4 {highlightedItem === partida.objeto ? 'ring-2 ring-blue-200 rounded-lg p-2 -ml-2' : ''}"
class="scroll-mt-4"
style="{highlightedItem === partida.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem; padding: 0.5rem; margin-left: -0.5rem;` : ''}"
>
<div class="flex items-start gap-3">
<span class="font-mono text-xs text-slate-400 pt-0.5">{partida.objeto}</span>
<div class="flex-1">
<h4 class="text-sm font-medium text-slate-800 flex items-center gap-2">
<div class="flex items-start gap-2 sm:gap-3">
<span class="font-mono text-xs pt-0.5 shrink-0" style="color: var(--theme-texto);">{partida.objeto}</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">{partida.desc_objeto}</span>
{#if partida.n_variaciones > 1}
<button
......@@ -360,7 +1309,8 @@
{/if}
<a
href="/objeto/{partida.objeto}"
class="text-slate-300 hover:text-blue-500 transition-colors"
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">
......@@ -369,25 +1319,26 @@
</a>
</h4>
{#if partidaDescs.length > 0}
<p class="text-xs text-slate-500 mt-1 leading-relaxed">{partidaDescs[0].descripcion}</p>
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{partidaDescs[0].descripcion}</p>
{/if}
</div>
</div>
<!-- Subpartidas -->
{#if partida.subpartidas?.length > 0}
<div class="ml-12 mt-3 space-y-2 border-l border-slate-100 pl-4">
<div class="ml-4 sm:ml-8 lg:ml-14 mt-4 space-y-3 border-l pl-4 lg:pl-6" style="border-color: color-mix(in srgb, var(--theme-borde) 60%, transparent);">
{#each partida.subpartidas as subpartida}
{@const subpartidaDescs = parseDescripciones(subpartida.descripciones)}
<div
id="sp-{subpartida.objeto}"
class="scroll-mt-4 {highlightedItem === subpartida.objeto ? 'ring-2 ring-blue-200 rounded p-1 -ml-1' : ''}"
class="scroll-mt-4"
style="{highlightedItem === subpartida.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.25rem; padding: 0.25rem; margin-left: -0.25rem;` : ''}"
>
<div class="flex items-start gap-2">
<span class="font-mono text-xs text-slate-300">{subpartida.objeto}</span>
<span class="font-mono text-xs" style="color: var(--theme-texto);">{subpartida.objeto}</span>
<div class="flex-1">
<span class="flex items-center gap-2">
<span class="text-xs text-slate-700">{subpartida.desc_objeto}</span>
<span class="text-sm" style="color: var(--theme-titulo);">{subpartida.desc_objeto}</span>
{#if subpartida.n_variaciones > 1}
<button
class="text-xs text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
......@@ -399,7 +1350,8 @@
{/if}
<a
href="/objeto/{subpartida.objeto}"
class="text-slate-300 hover:text-blue-500 transition-colors"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......@@ -408,7 +1360,7 @@
</a>
</span>
{#if subpartidaDescs.length > 0}
<p class="text-xs text-slate-400 mt-0.5">{subpartidaDescs[0].descripcion}</p>
<p class="text-sm mt-1" style="color: var(--theme-texto);">{subpartidaDescs[0].descripcion}</p>
{/if}
</div>
</div>
......@@ -428,33 +1380,35 @@
</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">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-3">En esta página</p>
<aside class="w-48 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto py-8 pl-8 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="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.objeto}"
class="block text-sm text-slate-600 hover:text-blue-600 truncate"
class="block text-sm truncate transition-colors hover:opacity-80"
style="color: var(--theme-titulo);"
title="{subgrupo.desc_objeto}"
>
{subgrupo.desc_objeto}
</a>
{#if subgrupo.partidas?.length > 0}
<div class="ml-3 mt-1 space-y-1 border-l border-slate-100 pl-2">
<div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: color-mix(in srgb, var(--theme-borde) 50%, transparent);">
{#each subgrupo.partidas.slice(0, 5) as partida}
<a
href="#p-{partida.objeto}"
class="block text-xs text-slate-400 hover:text-blue-600 truncate"
class="block text-xs truncate transition-colors hover:opacity-80"
style="color: var(--theme-texto);"
title="{partida.desc_objeto}"
>
{partida.desc_objeto}
</a>
{/each}
{#if subgrupo.partidas.length > 5}
<span class="text-xs text-slate-300">+{subgrupo.partidas.length - 5} más</span>
<span class="text-xs" style="color: var(--theme-texto);">+{subgrupo.partidas.length - 5} más</span>
{/if}
</div>
{/if}
......@@ -465,6 +1419,487 @@
</div>
</aside>
</div>
{:else if viewMode === 'mapa'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO MAPA: Treemap de proporciones del gasto -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 pt-3 pb-4">
<!-- Breadcrumb de navegación (solo en modo jerárquico) -->
{#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 0}
<nav class="flex items-center gap-2 mb-2 flex-wrap" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
{#each treemapBreadcrumb as crumb, i}
{#if i > 0}
<span style="color: var(--theme-texto); opacity: 0.5;">›</span>
{/if}
<button
onclick={() => drillUp(i)}
class="px-2 py-1 rounded transition-colors"
style="color: {i === treemapBreadcrumb.length - 1 ? 'var(--theme-titulo)' : 'var(--theme-texto)'}; background-color: {i === treemapBreadcrumb.length - 1 ? 'var(--theme-fill)' : 'transparent'}; cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = i === treemapBreadcrumb.length - 1 ? 'var(--theme-fill)' : 'transparent'}
>
{crumb.name}
</button>
{/each}
</nav>
{:else if treemapViewLevel !== 'jerarquico'}
<!-- Info de nivel aplanado -->
<div class="mb-2 flex items-center gap-3" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<span class="px-2 py-1 rounded" style="background-color: var(--theme-fill); color: var(--theme-titulo);">
{NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || treemapViewLevel}
</span>
<span style="color: var(--theme-texto);">
{treemapNodes.filter(n => n.type === 'item').length} categorías en {treemapNodes.filter(n => n.type === 'grupo').length} grupos
</span>
</div>
{/if}
<!-- Contenedor del Treemap -->
<div
bind:this={treemapContainer}
class="relative rounded-xl overflow-hidden w-full"
style="height: {treemapHeight}px;"
>
{#if treemapNodes.length > 0 && treemapWidth > 0}
<svg
width={treemapWidth}
height={treemapHeight}
class="block"
>
{#if treemapViewLevel === 'jerarquico'}
<!-- MODO JERÁRQUICO: con drill-down -->
{#each treemapNodes as node}
{@const width = node.x1 - node.x0}
{@const height = node.y1 - node.y0}
{@const hasChildren = node._originalNode?.children && node._originalNode.children.length > 0}
{@const isHovered = hoveredNode === node}
{@const textColor = getTextColor(node.id)}
{@const textColorSecondary = getTextColorSecondary(node.id)}
{@const label = node.data?.desc_objeto || node.name || node.id}
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onclick={() => hasChildren && drillDown(node)}
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: {hasChildren ? 'pointer' : 'default'};"
>
<rect
width={width}
height={height}
fill={getNodeColor(node.id, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'var(--theme-surface)'}
stroke-width={isHovered ? 2 : 1}
rx="2"
/>
{#if width > 60 && height > 40}
<foreignObject x="6" y="6" width={width - 12} height={height - 12}>
<div class="h-full flex flex-col justify-between overflow-hidden" style="font-family: 'Instrument Sans', sans-serif;">
<div class="font-medium leading-tight" style="font-size: {Math.min(14, Math.max(10, width / 12))}px; color: {textColor};">
{#if width > 100}
{label}
{:else}
{label.slice(0, 20)}{label.length > 20 ? '…' : ''}
{/if}
</div>
{#if height > 55}
<div style="font-family: 'DM Mono', monospace; font-size: {Math.min(12, Math.max(9, width / 14))}px; color: {textColorSecondary};">
{formatMoney(node.value)}
{#if width > 90 && height > 70}
<span style="opacity: 0.7;"> · {formatPerCapita(node.value)}</span>
{/if}
</div>
{/if}
</div>
</foreignObject>
{/if}
{#if hasChildren && width > 30 && height > 30}
<circle
cx={width - 12}
cy={12}
r="8"
fill={textColor.includes('255') ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.15)'}
class="transition-opacity"
style="opacity: {isHovered ? 1 : 0.5};"
/>
<text
x={width - 12}
y={16}
text-anchor="middle"
fill={textColor}
font-size="10"
font-weight="bold"
>+</text>
{/if}
</g>
{/each}
{:else}
<!-- MODO APLANADO: grupos con títulos + items -->
{#each treemapNodes as node}
{@const width = node.x1 - node.x0}
{@const height = node.y1 - node.y0}
{@const isHovered = hoveredNode === node}
{@const grupoId = node.grupoCode + '0000'}
{@const textColor = getTextColor(grupoId)}
{@const textColorSecondary = getTextColorSecondary(grupoId)}
{#if node.type === 'grupo'}
<!-- Contenedor de grupo con título -->
<g class="treemap-node" transform="translate({node.x0}, {node.y0})">
<!-- Fondo del grupo (sutil) -->
<rect
width={width}
height={height}
fill={getNodeColor(grupoId, 0.08)}
stroke={getNodeColor(grupoId, 0.3)}
stroke-width="1"
rx="3"
/>
<!-- Título del grupo -->
{#if width > 50}
<text
x="6"
y="14"
fill={getNodeColor(grupoId, 1)}
font-size="10"
font-weight="600"
style="font-family: 'DM Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px;"
>
{#if width > 180}
{node.name}
{:else if width > 100}
{node.name.slice(0, 18)}{node.name.length > 18 ? '…' : ''}
{:else if width > 60}
{node.name.slice(0, 10)}…
{:else}
G{node.grupoCode}
{/if}
</text>
{/if}
</g>
{:else}
<!-- Item individual -->
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
>
<rect
width={width}
height={height}
fill={getNodeColor(grupoId, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
stroke-width={isHovered ? 2 : 0}
rx="2"
/>
{#if width > 40 && height > 25}
<foreignObject x="4" y="4" width={width - 8} height={height - 8}>
<div class="h-full flex flex-col justify-between overflow-hidden" style="font-family: 'Instrument Sans', sans-serif;">
<div class="font-medium leading-tight" style="font-size: {Math.min(12, Math.max(9, width / 14))}px; color: {textColor};">
{#if width > 80}
{(node.name || node.id).slice(0, Math.floor(width / 6))}{(node.name || node.id).length > Math.floor(width / 6) ? '…' : ''}
{:else}
{node.id}
{/if}
</div>
{#if height > 40 && width > 50}
<div style="font-family: 'DM Mono', monospace; font-size: {Math.min(10, Math.max(8, width / 16))}px; color: {textColorSecondary};">
{formatMoney(node.value)}
{#if width > 80 && height > 55}
<span style="opacity: 0.7;"> · {formatPerCapita(node.value)}</span>
{/if}
</div>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/if}
{/each}
{/if}
</svg>
<!-- Tooltip flotante -->
{#if hoveredNode && hoveredNode.type !== 'grupo'}
{@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
<div
class="absolute pointer-events-none px-3 py-2 rounded-lg shadow-lg z-10 max-w-xs"
style="
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 220)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
background-color: var(--theme-titulo);
color: var(--theme-body);
font-family: 'Instrument Sans', sans-serif;
"
>
{#if treemapViewLevel !== 'jerarquico'}
<!-- Vista aplanada: mostrar contexto del grupo -->
<div class="text-xs opacity-60 mb-1" style="font-family: 'DM Mono', monospace;">
{hoveredNode.id}
</div>
<div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1" style="font-family: 'DM Mono', monospace;">
{formatMoney(hoveredNode.value)} · {((hoveredNode.value / totalValue) * 100).toFixed(1)}%
</div>
<div class="text-xs opacity-60 mt-0.5" style="font-family: 'DM Mono', monospace;">
{formatPerCapita(hoveredNode.value)}
</div>
{:else}
<!-- Vista jerárquica -->
<div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1" style="font-family: 'DM Mono', monospace;">
{formatMoney(hoveredNode.value)}
{#if currentTreemapNode && currentTreemapNode.value}
· {((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}%
{/if}
</div>
<div class="text-xs opacity-60 mt-0.5" style="font-family: 'DM Mono', monospace;">
{formatPerCapita(hoveredNode.value)}
</div>
{#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0}
<div class="text-xs opacity-60 mt-1">Clic para explorar</div>
{/if}
{/if}
</div>
{/if}
{:else}
<!-- Estado vacío / cargando -->
<div class="absolute inset-0 flex flex-col items-center justify-center p-8">
<svg class="w-12 h-12 mb-4 animate-pulse" style="color: var(--theme-texto); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
<p class="text-sm" style="color: var(--theme-texto);">Cargando visualización...</p>
</div>
{/if}
</div>
</div>
{:else if viewMode === 'comparar'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO COMPARAR: Dos treemaps lado a lado -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-8">
<!-- Selectores de comparación -->
<div class="flex flex-col lg:flex-row gap-6 lg:gap-8 mb-8">
<!-- Lado A -->
<div class="flex-1">
<div class="flex items-center gap-3 mb-4">
<span class="w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">A</span>
<div class="flex flex-wrap items-center gap-2">
<!-- Año A -->
<select
bind:value={compareA.year}
class="px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 cursor-pointer font-medium"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
>
{#each availableYears as year}
<option value={year}>{year}</option>
{/each}
</select>
<!-- Entidad A -->
<div class="relative compare-a-dropdown">
<button
class="px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 min-w-[200px] text-left flex items-center justify-between gap-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
onclick={() => compareADropdownOpen = !compareADropdownOpen}
>
<span class="truncate font-medium">
{#if compareA.entity}
{compareA.entity.sigla_entidad || compareA.entity.desc_entidad?.slice(0, 25)}
{:else}
Todo el Estado
{/if}
</span>
<svg class="w-4 h-4 shrink-0" style="color: var(--theme-texto);" 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>
{#if compareADropdownOpen}
<div class="absolute top-full left-0 mt-1 w-80 rounded-lg shadow-lg z-50 max-h-72 overflow-hidden" style="background-color: var(--theme-surface); border: 1px solid var(--theme-borde);">
<div class="p-2 border-b" style="border-color: var(--theme-borde);">
<input
type="text"
bind:value={compareASearchQuery}
placeholder="Buscar entidad..."
class="w-full px-3 py-1.5 text-sm rounded focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-fill); color: var(--theme-titulo);"
/>
</div>
<button
class="w-full text-left px-3 py-2 text-sm border-b"
style="{!compareA.entity ? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);` : ``} color: var(--theme-titulo); border-color: var(--theme-borde);"
onclick={() => clearCompareEntity('A')}
>
<span class="font-medium">Todo el Estado</span>
</button>
<div class="overflow-y-auto max-h-48">
{#each filteredEntitiesA() as entity}
<button
class="w-full text-left px-3 py-2 text-sm"
style="{compareA.entity?.entidad === entity.entidad ? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);` : ``} color: var(--theme-titulo);"
onclick={() => selectCompareEntity('A', entity)}
>
<div class="flex items-center gap-2">
<span class="font-mono text-xs" style="color: var(--theme-texto);">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Treemap A placeholder -->
<div class="border-2 border-dashed rounded-xl min-h-[350px] flex flex-col items-center justify-center p-6" style="background-color: color-mix(in srgb, var(--theme-accent) 5%, transparent); border-color: color-mix(in srgb, var(--theme-accent) 30%, transparent);">
<svg class="w-12 h-12 mb-3" style="color: var(--theme-accent); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
<p class="font-medium" style="color: var(--theme-accent);">Treemap A</p>
<p class="text-sm mt-1" style="color: var(--theme-texto);">
{compareA.year} · {compareA.entity?.sigla_entidad || 'Todo el Estado'}
</p>
</div>
</div>
<!-- Botón de intercambio (centro) -->
<div class="flex lg:flex-col items-center justify-center gap-2">
<button
onclick={swapCompareSides}
class="p-2 rounded-full border transition-colors"
style="border-color: var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-texto);"
title="Intercambiar lados"
>
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</button>
<span class="font-medium text-sm" style="color: var(--theme-texto);">vs</span>
</div>
<!-- Lado B -->
<div class="flex-1">
<div class="flex items-center gap-3 mb-4">
<span class="w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm" style="background-color: color-mix(in srgb, #8b5cf6 20%, transparent); color: #8b5cf6;">B</span>
<div class="flex flex-wrap items-center gap-2">
<!-- Año B -->
<select
bind:value={compareB.year}
class="px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 cursor-pointer font-medium"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
>
{#each availableYears as year}
<option value={year}>{year}</option>
{/each}
</select>
<!-- Entidad B -->
<div class="relative compare-b-dropdown">
<button
class="px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 min-w-[200px] text-left flex items-center justify-between gap-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
onclick={() => compareBDropdownOpen = !compareBDropdownOpen}
>
<span class="truncate font-medium">
{#if compareB.entity}
{compareB.entity.sigla_entidad || compareB.entity.desc_entidad?.slice(0, 25)}
{:else}
Todo el Estado
{/if}
</span>
<svg class="w-4 h-4 shrink-0" style="color: var(--theme-texto);" 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>
{#if compareBDropdownOpen}
<div class="absolute top-full left-0 mt-1 w-80 rounded-lg shadow-lg z-50 max-h-72 overflow-hidden" style="background-color: var(--theme-surface); border: 1px solid var(--theme-borde);">
<div class="p-2 border-b" style="border-color: var(--theme-borde);">
<input
type="text"
bind:value={compareBSearchQuery}
placeholder="Buscar entidad..."
class="w-full px-3 py-1.5 text-sm rounded focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-fill); color: var(--theme-titulo);"
/>
</div>
<button
class="w-full text-left px-3 py-2 text-sm border-b"
style="{!compareB.entity ? `background-color: color-mix(in srgb, #8b5cf6 15%, transparent);` : ``} color: var(--theme-titulo); border-color: var(--theme-borde);"
onclick={() => clearCompareEntity('B')}
>
<span class="font-medium">Todo el Estado</span>
</button>
<div class="overflow-y-auto max-h-48">
{#each filteredEntitiesB() as entity}
<button
class="w-full text-left px-3 py-2 text-sm"
style="{compareB.entity?.entidad === entity.entidad ? `background-color: color-mix(in srgb, #8b5cf6 15%, transparent);` : ``} color: var(--theme-titulo);"
onclick={() => selectCompareEntity('B', entity)}
>
<div class="flex items-center gap-2">
<span class="font-mono text-xs" style="color: var(--theme-texto);">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Treemap B placeholder -->
<div class="border-2 border-dashed rounded-xl min-h-[350px] flex flex-col items-center justify-center p-6" style="background-color: color-mix(in srgb, #8b5cf6 5%, transparent); border-color: color-mix(in srgb, #8b5cf6 30%, transparent);">
<svg class="w-12 h-12 mb-3" style="color: #8b5cf6; opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
<p class="font-medium" style="color: #8b5cf6;">Treemap B</p>
<p class="text-sm mt-1" style="color: var(--theme-texto);">
{compareB.year} · {compareB.entity?.sigla_entidad || 'Todo el Estado'}
</p>
</div>
</div>
</div>
<!-- Resumen de diferencias (placeholder) -->
<div class="border rounded-xl p-6" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<h3 class="text-sm font-medium uppercase tracking-wide mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-titulo);">
Diferencias principales
</h3>
<div class="grid sm:grid-cols-3 gap-4 text-center">
<div class="p-4 rounded-lg border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--theme-texto);">Mayor crecimiento</p>
<p class="text-sm italic" style="color: var(--theme-texto);">Pendiente de datos</p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--theme-texto);">Mayor reducción</p>
<p class="text-sm italic" style="color: var(--theme-texto);">Pendiente de datos</p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--theme-surface); border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--theme-texto);">Cambio total</p>
<p class="text-sm italic" style="color: var(--theme-texto);">Pendiente de datos</p>
</div>
</div>
</div>
</div>
{/if}
{/if}
</div>
......@@ -472,28 +1907,30 @@
{#if selectedItem}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onclick={closeDetail}>
<div
class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
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 bg-slate-50 flex justify-between items-start">
<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 text-slate-500 uppercase tracking-wide">{getNivelLabel(selectedItem.nivel)}</p>
<h3 class="text-lg font-medium text-slate-900 mt-1">
<span class="font-mono text-slate-400">{selectedItem.objeto}</span>
<span class="mx-2">·</span>
<p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">{getNivelLabel(selectedItem.nivel)}</p>
<h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
<span class="font-mono" style="color: var(--theme-texto);">{selectedItem.objeto}</span>
<span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
{selectedItem.desc_objeto}
</h3>
</div>
<button
onclick={closeDetail}
class="text-slate-400 hover:text-slate-600 text-2xl leading-none"
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 text-slate-700 mb-4">
<h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
{#if selectedItem.n_variaciones > 1}
Descripciones ({selectedItem.n_variaciones} variaciones)
{:else}
......@@ -503,15 +1940,15 @@
<div class="space-y-4">
{#each parseDescripciones(selectedItem.descripciones) as desc, i}
<div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200'} pl-4 py-2 rounded-r">
<p class="text-xs text-slate-500 mb-2">
<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="text-blue-600 font-medium">Vigente</span>
<span class="mx-1">·</span>
<span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
<span class="mx-1" style="opacity: 0.5;">·</span>
{/if}
{desc.rangos}
<span class="font-mono">{desc.rangos}</span>
</p>
<p class="text-sm text-slate-700 leading-relaxed">{desc.descripcion}</p>
<p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
</div>
{/each}
</div>
......@@ -519,3 +1956,29 @@
</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;
}
}
/* Transiciones suaves para el treemap */
:global(.treemap-node) {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
:global(.treemap-node rect) {
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
fill 0.3s ease;
}
:global(.treemap-fade-enter) {
opacity: 0;
transform: scale(0.95);
}
</style>
......
# Estilos UI - Dark Mode, Menu y Drawer
## 1. Variables CSS (app.css)
```css
/* Light mode colors */
:root {
--color-light-body: #f5ebe0;
--color-light-background: #f3ead8;
--color-light-titulo: #363534;
--color-light-texto-auxiliar: #676767;
--color-light-fill: #f3ead8;
--color-light-borde: rgba(0, 0, 0, 0.08);
--color-light-focus-secondary: #676767;
--color-light-accent: #c9a751;
}
/* Dark mode colors */
:root.dark {
--color-light-body: #0d0d0d;
--color-light-background: #1a1a1a;
--color-light-titulo: #e8e4d8;
--color-light-texto-auxiliar: #b8b3a3;
--color-light-fill: #2d2d2d;
--color-light-borde: rgba(255, 255, 255, 0.1);
--color-light-focus-secondary: #c9a751;
--color-light-accent: #c9a751;
}
```
---
## 2. Inicialización del tema (app.html)
Evita el flash de tema incorrecto cargando la preferencia antes del render.
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Theme initialization - ANTES de cargar CSS -->
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
let isDark = false;
if (savedTheme) {
isDark = savedTheme === 'dark';
}
if (isDark) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<!-- contenido -->
</body>
</html>
```
---
## 3. BARRA DE CONTROLES (Pastilla con iconos)
Esta es la barra que contiene: **Home + Theme Toggle + Menú Hamburguesa**
### Estructura completa
```svelte
<script>
import { onMount } from 'svelte';
let isDark = $state(false);
let drawerOpen = $state(false);
function toggleTheme() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
function toggleDrawer() {
drawerOpen = !drawerOpen;
}
onMount(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
isDark = savedTheme === 'dark';
}
document.documentElement.classList.toggle('dark', isDark);
});
</script>
<!-- BARRA DE CONTROLES - Esquina superior derecha -->
<div class="fixed top-6 right-6 z-[110]">
<div class="flex items-center gap-1">
<!-- ========== BOTÓN HOME (4 cuadrados) ========== -->
<div class="relative group/home">
<a
href="/"
class="p-2 rounded-full transition-all block"
style="background-color: transparent; color: var(--color-light-texto-auxiliar);"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</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">
Página principal
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- ========== BOTÓN THEME TOGGLE (Sol/Luna) ========== -->
<div class="relative group/theme">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDark}
<!-- LUNA (modo oscuro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<!-- SOL (modo claro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
<!-- 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/theme:opacity-100 group-hover/theme:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
{isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- ========== BOTÓN MENÚ HAMBURGUESA ========== -->
<div class="relative group/menu">
<button
onclick={toggleDrawer}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
<!-- 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/menu:opacity-100 group-hover/menu:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Menú
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
</div>
</div>
```
### Estilos clave de cada botón
| Propiedad | Valor |
|-----------|-------|
| Padding | `p-2` (8px) |
| Forma | `rounded-full` (circular) |
| Fondo | `transparent` |
| Color normal | `var(--color-light-texto-auxiliar)` |
| Color hover | `var(--color-light-titulo)` |
| Tamaño icono | `w-5 h-5` (20px) |
| Transición | `transition-all` |
| Cursor | `cursor: pointer` |
### Estilos del contenedor (la "pastilla")
| Propiedad | Valor |
|-----------|-------|
| Layout | `flex items-center` |
| Espaciado | `gap-1` (4px entre iconos) |
| Posición | `fixed top-6 right-6` |
| Z-index | `z-[110]` |
### Tooltip (aparece abajo del icono)
| Propiedad | Valor |
|-----------|-------|
| Posición | `absolute right-0 top-full mt-2` |
| Fondo | `bg-gray-900/90` |
| Texto | `text-white text-xs` |
| Padding | `px-3 py-1.5` |
| Bordes | `rounded-lg` |
| Efecto | `backdrop-blur-sm` |
| Transición | `opacity-0 invisible``opacity-100 visible` en hover |
| Flecha | `w-2 h-2 bg-gray-900/90 rotate-45` posicionada arriba |
---
## 4. VERSIÓN MÓVIL (Solo hamburguesa + theme en drawer)
En móvil, solo se muestra el botón hamburguesa. El theme toggle va dentro del drawer.
```svelte
<!-- Móvil: solo hamburguesa arriba a la derecha -->
<div class="fixed top-2 right-2 z-[110] md:hidden">
<button
onclick={toggleDrawer}
class="p-2 rounded-full transition-all flex-shrink-0"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
</div>
<!-- Desktop: barra completa con home + theme + menu -->
<div class="hidden md:flex fixed top-6 right-6 z-[110]">
<!-- ... los 3 botones ... -->
</div>
```
---
## 5. NavigationDrawer.svelte (Drawer completo)
```svelte
<script>
import { onMount } from 'svelte';
let { isOpen = $bindable(false) } = $props();
let isDarkMode = $state(false);
// Detectar cambios de tema
onMount(() => {
isDarkMode = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDarkMode = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
});
function closeDrawer() {
isOpen = false;
}
function toggleTheme() {
isDarkMode = !isDarkMode;
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}
</script>
<!-- Overlay (fondo oscuro) -->
{#if isOpen}
<div
class="fixed inset-0 bg-black/40 z-[200]"
style="opacity: {isOpen ? '1' : '0'}; transition: opacity 0.2s ease-out;"
onclick={closeDrawer}
></div>
{/if}
<!-- Drawer -->
<aside
class="fixed top-0 right-0 h-full w-80 border-l shadow-2xl z-[201]"
style="
background-color: var(--color-light-body);
border-color: rgba(0, 0, 0, 0.05);
transform: {isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)'};
transition: transform 0.3s cubic-bezier(0.33, 1, 0.68, 1);
"
>
<div class="flex flex-col h-full">
<!-- Header del Drawer -->
<div class="py-3 md:py-4 px-4 md:px-6 border-b flex-shrink-0 relative" style="border-color: rgba(0, 0, 0, 0.05);">
<!-- Theme Toggle - SOLO en móvil (arriba izquierda del drawer) -->
<div class="absolute top-3 md:top-4 left-4 md:left-6 md:hidden">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
>
{#if isDarkMode}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
</div>
<!-- Botón cerrar (X) - arriba derecha -->
<div class="group absolute top-3 md:top-4 right-4 md:right-6">
<button
onclick={closeDrawer}
class="p-2 rounded-lg transition-colors flex-shrink-0"
style="color: var(--color-light-focus-secondary); cursor: pointer;"
aria-label="Cerrar menú"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Tooltip del botón cerrar -->
<div class="absolute left-1/2 -translate-x-1/2 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:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Cerrar menú
<div class="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- Logo centrado (cambia según tema) -->
<div class="flex flex-col items-center">
<img
src={isDarkMode ? "/logos_oscuro/logo.png" : "/logos_claro/logo.png"}
alt="Logo"
class="h-24 md:h-32 w-auto mb-3"
/>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto p-4">
<!-- Item de navegación con hover -->
<a
href="/"
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left group"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-light-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--color-light-focus-secondary);" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</svg>
<span class="font-medium text-sm" style="color: var(--color-light-titulo);">Página principal</span>
</a>
<!-- Sección con título -->
<div class="mb-6 mt-4">
<h3 class="text-xs font-semibold uppercase tracking-wider mb-3 px-2" style="color: var(--color-light-focus-secondary);">
Sección
</h3>
<div class="space-y-1">
<!-- Items de navegación aquí -->
</div>
</div>
</nav>
<!-- Footer -->
<div class="p-4 border-t flex-shrink-0" style="border-color: rgba(0, 0, 0, 0.05);">
<p class="text-xs text-center" style="color: var(--color-light-focus-secondary);">
© 2026
</p>
</div>
</div>
</aside>
```
---
## 6. Iconos SVG
### Sol (modo claro activo)
```html
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
```
### Luna (modo oscuro activo)
```html
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
```
### Hamburguesa (menú)
```html
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
```
### X (cerrar)
```html
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
```
### Home (4 cuadrados)
```html
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</svg>
```
---
## 7. Resumen de comportamiento
| Viewport | Qué se ve | Theme toggle |
|----------|-----------|--------------|
| **Móvil** (`<768px`) | Solo hamburguesa arriba derecha | Dentro del drawer (arriba izquierda) |
| **Desktop** (`≥768px`) | Home + Theme + Hamburguesa | En la barra de controles |
### Animaciones
| Elemento | Animación |
|----------|-----------|
| Hover en iconos | Color cambia de `texto-auxiliar``titulo` |
| Tooltip | `opacity 0→1`, `invisible→visible` con `duration-200` |
| Drawer abre | `translate3d(100%, 0, 0)``translate3d(0, 0, 0)` con `cubic-bezier(0.33, 1, 0.68, 1)` |
| Overlay | `opacity 0→1` con `0.2s ease-out` |
gestion,objeto,devengado
2025,11100,7010195151.280003
2025,11210,5812831318.110004
2025,11220,3960643633.5099993
2025,11310,477377380.7399999
2025,11321,611358929.5699998
2025,11322,344284069.2800001
2025,11323,8492765.11
2025,11324,119472850.73000005
2025,11331,3741918.29
2025,11332,222831.86
2025,11339,991911009.5900005
2025,11400,2598265843.6899953
2025,11510,115618445.64
2025,11520,3684768.92
2025,11600,347937539.3
2025,11700,16012422722.88999
2025,11810,1897633.19
2025,11820,5236920.13
2025,11910,418427037.15000004
2025,11920,49552831.83999999
2025,11930,141140855.32999998
2025,11940,4603197.24
2025,12100,3204535824.6000023
2025,13110,3901953807.7400007
2025,13120,619414677.6200007
2025,13131,1362025647.0700052
2025,13132,16517337.85
2025,13200,780321050.7899984
2025,14100,1139341529.1000001
2025,15100,0
2025,15200,0
2025,15300,0
2025,15400,4691146.74
2025,21100,18994647.069999997
2025,21200,1185053965.7300012
2025,21300,187133399.53000006
2025,21400,53465476.169999935
2025,21500,32025660.46999999
2025,21600,194624988.81999987
2025,22110,91267157.03
2025,22120,18054096.22
2025,22210,150388054.74000007
2025,22220,34629325.89
2025,22300,1959574229.3100035
2025,22400,2341473.77
2025,22500,701307983.6299998
2025,22600,129774059.36000001
2025,23100,177818037.08
2025,23200,705817678.630001
2025,23300,267332.29
2025,23400,102877216.77999996
2025,24110,347305124.4300007
2025,24120,495875030.0700008
2025,24130,4734068.450000001
2025,24200,186441122.7200001
2025,24300,2640074895.480003
2025,25120,968577449.3700005
2025,25130,48331129.9
2025,25210,124279655.01999998
2025,25220,1788157637.100003
2025,25230,38750297.28
2025,25300,561398734.3999997
2025,25400,878953716.1200004
2025,25500,306587141.63999945
2025,25600,313083444.33000004
2025,25700,6976563.890000001
2025,25810,119466843.19999999
2025,25820,224542509.50000012
2025,25900,237665083.13999984
2025,26200,15655261.7
2025,26300,34604066.51
2025,26610,123238100.68000004
2025,26620,65803840.900000006
2025,26630,5856917.720000001
2025,26640,12578208.32
2025,26700,14848951.62
2025,26910,663385.5700000003
2025,26920,235250
2025,26930,8978735.689999998
2025,26940,66563836.099999994
2025,26990,1503662082.0000036
2025,27110,1825790733.1499996
2025,27120,364659427.3
2025,31110,542443427.4200002
2025,31120,216381918.43999982
2025,31130,664933780.7900007
2025,31140,546467610.53
2025,31150,58934973.47
2025,31200,33094567.35000001
2025,31300,2447995585.1100035
2025,32100,143276681.52000007
2025,32200,132315063.70000002
2025,32300,5561928.509999998
2025,32400,159320
2025,32500,1136682.8700000003
2025,33100,24196989.06
2025,33200,44402437.73000002
2025,33300,140159482.78000006
2025,33400,44503112.73000003
2025,34110,1233206780.699983
2025,34120,22281523272.74
2025,34130,210854679.98
2025,34200,2971103666.2100005
2025,34300,107990911.47999994
2025,34400,7452099.93
2025,34500,643334205.2399997
2025,34600,256516535.3800006
2025,34700,4499642859.289998
2025,34800,31720752.09
2025,34900,3886088.93
2025,39100,110497000.54
2025,39200,48738034.710000016
2025,39300,4137761.2899999996
2025,39400,88709616.89000002
2025,39500,308909532.08000034
2025,39600,13789089.190000001
2025,39700,260527546.88999993
2025,39800,711478491.3899997
2025,39911,25451599.84
2025,39912,229716435.09
2025,39990,85810785.37999989
2025,41100,4018555.16
2025,41200,11738231.740000002
2025,41300,0
2025,42210,2475688.8800000004
2025,42220,5772449.3
2025,42230,3644887583.5699887
2025,42240,119884214.51000004
2025,42310,3262092590.7700067
2025,42320,162411814.5
2025,42400,748574159.99
2025,42500,368399418.97999996
2025,43110,68317124.34
2025,43120,223834030.73999995
2025,43200,145257372.71
2025,43310,18770602.6
2025,43320,7603011.960000001
2025,43330,290260838.97
2025,43340,4231983.279999999
2025,43400,398948895.01000005
2025,43500,328470832.01999986
2025,43600,51696765.46999999
2025,43700,169086594.95000005
2025,46110,125733899.86000003
2025,46120,74691013.75999999
2025,46210,57348534.56999999
2025,46220,9583654.360000001
2025,46310,30831570.299999986
2025,46320,2784494.7899999996
2025,49100,41480273
2025,49300,492862.94999999995
2025,49400,85275
2025,49900,660922.38
2025,51100,0
2025,51200,620015014.37
2025,51600,481042090
2025,51700,29014116.87
2025,53410,52050949.82
2025,53420,351090144.51
2025,53430,3378151.15
2025,53440,25716472.12
2025,54300,77289717.23
2025,54800,632675260.65
2025,55130,0
2025,56100,2147721911.8500004
2025,57100,836275.9
2025,57200,0
2025,61100,70227731033.1
2025,61200,3503973693.6899977
2025,61300,7686201.74
2025,61400,165316.78
2025,61600,4660620766.32
2025,61700,3610015023.1800003
2025,61800,578767.5200000001
2025,61900,560031.13
2025,62100,106585021.14999999
2025,62200,38756619.58
2025,62300,3500499.439999999
2025,62400,405.55
2025,62600,4871024753.510001
2025,62700,3091378138.92
2025,62800,66184743.53000001
2025,62900,236631.16
2025,63100,6480763.82
2025,63200,0
2025,63300,100360.43
2025,63400,462857.7
2025,63500,35529.2
2025,63600,0
2025,63700,2369055.65
2025,63800,0
2025,63900,1087614.1800000002
2025,64100,0
2025,64200,0
2025,65100,586447491.8299999
2025,65210,398987753.5799999
2025,65220,240387686.01999998
2025,65230,132581381.01
2025,65240,0
2025,65300,2444818356.6
2025,65400,644864385.6300001
2025,65500,797927308.79
2025,65600,622100032.9
2025,65800,6897016.98
2025,65900,66705420.480000004
2025,66100,403828926.02000004
2025,66210,2597435653.4000015
2025,66220,1829365908.85
2025,66230,646299428.4800001
2025,66240,80232240.73
2025,66250,209697820.00000003
2025,66300,239921321.82000005
2025,66400,134798065.3299999
2025,66900,1844595842.08
2025,67100,23208064.040000003
2025,68200,230881944.88000008
2025,69100,29581681.77
2025,69200,203500678.33
2025,71100,7630007944.97
2025,71210,2392898.6
2025,71220,322149243.09
2025,71230,4408152.6
2025,71300,26644234.430000003
2025,71610,1883091507.0600004
2025,71630,343800974.93
2025,71700,65080225.449999996
2025,71800,4478984170.690001
2025,72200,2046897058.8299997
2025,72420,316010589.22
2025,72520,7584584602.65001
2025,73100,2664574022.970007
2025,73200,24681254941.659996
2025,73410,4845222515.11
2025,73420,315258596.03999996
2025,73430,4314037.58
2025,73440,1652127
2025,73700,15015040108.359999
2025,73820,1364681.67
2025,75110,0
2025,75120,683772757.9200002
2025,75211,469127620.05
2025,75212,50308009.760000005
2025,75221,11649919.909999998
2025,75222,305831148.79999995
2025,75320,0
2025,77100,346626643.30999994
2025,77200,1783047515.49
2025,77410,154404059.40000007
2025,77440,13005182.149999999
2025,77520,34542653.12
2025,77530,1359464924.2300012
2025,77700,0
2025,77820,42931153.11
2025,78100,346333.4
2025,79100,20183063.04
2025,79200,74336.66
2025,79310,69600
2025,81100,189467352
2025,81200,1327189189.5399992
2025,81300,551652107.6699998
2025,81400,299412884.65
2025,81500,2442434
2025,81600,395569
2025,81950,39137
2025,81960,32790665.14
2025,81990,2007174694.16
2025,82100,879007438.5400001
2025,83110,29113391.8
2025,83120,4240482.35
2025,83210,351070.72
2025,83220,50460
2025,84100,65887753.279999994
2025,84230,394819507.59
2025,84240,723835763.5600002
2025,84250,65803251.14
2025,84900,8503359.91
2025,85100,94254502.63999999
2025,85200,10478575.300000003
2025,85400,6221225.630000003
2025,85500,3581209.3799999994
2025,85900,20640115.529999997
2025,86100,29028844.980000008
2025,91200,512102826.06
2025,92100,0
2025,94100,11526054.86
2025,94200,7279202.82
2025,94300,1551638.3499999999
2025,95100,291453722.56
2025,96100,5934986991.019999
2025,96200,614954091.44
2025,96900,2947311655.2299995
2025,97100,70099589.35000001
2025,98300,125000000
2025,99100,0
2025,99200,0
This diff could not be displayed because it is too large.