Rafael Lopez

site

<script>
let { size = 40, color = 'currentColor' } = $props();
</script>
<svg
fill={color}
viewBox="0 -10 40 28"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size * 0.7}
class="spinner"
>
<circle cx="8" cy="12" r="3" class="first"/>
<circle cx="16" cy="12" r="3"/>
<circle cx="24" cy="12" r="3"/>
<circle cx="32" cy="12" r="3" class="second"/>
</svg>
<style>
@keyframes swing {
0% {
transform: rotate(0deg);
animation-timing-function: ease-out;
}
25% {
transform: rotate(50deg);
animation-timing-function: ease-in;
}
50% {
transform: rotate(0deg);
animation-timing-function: linear;
}
}
@keyframes swing2 {
0% {
transform: rotate(0deg);
animation-timing-function: linear;
}
50% {
transform: rotate(0deg);
animation-timing-function: ease-out;
}
75% {
transform: rotate(-50deg);
animation-timing-function: ease-in;
}
}
.spinner .first {
animation: swing 1.2s linear infinite;
transform-origin: center top;
}
.spinner .second {
animation: swing2 1.2s linear infinite;
transform-origin: center top;
}
</style>
/**
* Cache store for clasificador data
* Persists data in memory to avoid reloading on navigation
*/
import { writable } from 'svelte/store';
// Cache structure
const createClasificadorCache = () => {
const { subscribe, set, update } = writable({
objetoGasto: {
allItems: null,
grupos: null,
entities: null,
availableYears: null,
lastFetched: null
}
});
return {
subscribe,
// Set all clasificador objeto data
setObjetoGasto: (data) => {
update(cache => ({
...cache,
objetoGasto: {
...data,
lastFetched: Date.now()
}
}));
},
// Get cached data if valid (less than 5 minutes old)
getObjetoGasto: (currentCache) => {
const cache = currentCache.objetoGasto;
if (!cache.lastFetched) return null;
// Cache valid for 5 minutes
const isValid = Date.now() - cache.lastFetched < 5 * 60 * 1000;
if (!isValid) return null;
return cache;
},
// Clear cache
clear: () => {
set({
objetoGasto: {
allItems: null,
grupos: null,
entities: null,
availableYears: null,
lastFetched: null
}
});
}
};
};
export const clasificadorCache = createClasificadorCache();
......@@ -4,6 +4,8 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as d3 from 'd3';
import { clasificadorCache } from '$lib/stores/clasificadorCache';
import Spinner from '$lib/components/ui/Spinner.svelte';
let loading = $state(true);
let searchQuery = $state('');
......@@ -37,6 +39,11 @@
let levelDropdownOpen = $state(false);
let yearDropdownOpen = $state(false);
// Límites dinámicos para scroll infinito en dropdowns
let entityLimit = $state(30);
let compareALimit = $state(30);
let compareBLimit = $state(30);
// Estado para modo comparar
let compareA = $state({ year: null, entity: null });
let compareB = $state({ year: null, entity: null });
......@@ -57,6 +64,7 @@
let treemapHeight = $state(500);
let treemapContainer = $state(null);
let hoveredNode = $state(null);
let showTreemapHelp = $state(false);
// Selector de nivel para vista aplanada
let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida'
......@@ -513,6 +521,30 @@
});
onMount(async () => {
// Intentar usar cache primero
let cacheValue;
const unsubscribe = clasificadorCache.subscribe(value => { cacheValue = value; });
unsubscribe();
const cachedData = clasificadorCache.getObjetoGasto(cacheValue);
if (cachedData && cachedData.allItems && cachedData.grupos && cachedData.entities && cachedData.availableYears) {
// Usar datos del cache
allItems = cachedData.allItems;
grupos = cachedData.grupos;
entities = cachedData.entities;
availableYears = cachedData.availableYears;
selectedYear = availableYears[0];
if (grupos.length > 0) {
selectedGrupo = grupos[0];
}
loading = false;
return;
}
// Si no hay cache, cargar datos
// Cargar clasificador de objetos
const { data, error } = await supabase
.schema('ppto')
......@@ -565,13 +597,21 @@
entities = entidadesData;
}
// Guardar en cache
clasificadorCache.setObjetoGasto({
allItems,
grupos,
entities,
availableYears
});
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
return entities.slice(0, entityLimit);
}
const q = entitySearchQuery.toLowerCase();
return entities
......@@ -580,25 +620,42 @@
e.sigla_entidad?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, 20);
.slice(0, entityLimit);
});
function selectEntity(entity) {
selectedEntity = entity;
entityDropdownOpen = false;
entitySearchQuery = '';
entityLimit = 30; // Reset limit
}
function clearEntity() {
selectedEntity = null;
entityDropdownOpen = false;
entitySearchQuery = '';
entityLimit = 30;
}
// Handler para cargar más entidades al hacer scroll
function handleEntityScroll(event, type) {
const el = event.target;
const threshold = 50; // pixels from bottom
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
if (type === 'main' && entityLimit < entities.length) {
entityLimit = Math.min(entityLimit + 30, entities.length);
} else if (type === 'A' && compareALimit < entities.length) {
compareALimit = Math.min(compareALimit + 30, entities.length);
} else if (type === 'B' && compareBLimit < entities.length) {
compareBLimit = Math.min(compareBLimit + 30, entities.length);
}
}
}
// Filtrar entidades para comparación A
let filteredEntitiesA = $derived(() => {
if (!compareASearchQuery || compareASearchQuery.length < 2) {
return entities.slice(0, 20);
return entities.slice(0, compareALimit);
}
const q = compareASearchQuery.toLowerCase();
return entities
......@@ -607,13 +664,13 @@
e.sigla_entidad?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, 20);
.slice(0, compareALimit);
});
// Filtrar entidades para comparación B
let filteredEntitiesB = $derived(() => {
if (!compareBSearchQuery || compareBSearchQuery.length < 2) {
return entities.slice(0, 20);
return entities.slice(0, compareBLimit);
}
const q = compareBSearchQuery.toLowerCase();
return entities
......@@ -622,7 +679,7 @@
e.sigla_entidad?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, 20);
.slice(0, compareBLimit);
});
// Inicializar modo comparar con valores inteligentes
......@@ -704,6 +761,7 @@
.sort((a, b) => a.objeto.localeCompare(b.objeto));
const subgrupos = subgruposUnicos.map(sg => {
// Obtener partidas existentes
const partidasUnicas = allItems
.filter(item => item.nivel === 'partida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo)
.reduce((acc, item) => {
......@@ -714,21 +772,62 @@
}, [])
.sort((a, b) => a.objeto.localeCompare(b.objeto));
// Obtener todas las subpartidas del subgrupo
const todasSubpartidas = allItems
.filter(item => item.nivel === 'subpartida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo)
.reduce((acc, item) => {
if (!acc.find(sp => sp.objeto === item.objeto)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => a.objeto.localeCompare(b.objeto));
// Set de números de partida que existen
const partidasExistentes = new Set(partidasUnicas.map(p => p.partida));
// Encontrar subpartidas huérfanas (cuya partida no existe)
const subpartidasHuerfanas = todasSubpartidas.filter(sp => !partidasExistentes.has(sp.partida));
// Agrupar huérfanas por número de partida para crear partidas sintéticas
const huerfanasPorPartida = {};
subpartidasHuerfanas.forEach(sp => {
if (!huerfanasPorPartida[sp.partida]) {
huerfanasPorPartida[sp.partida] = [];
}
huerfanasPorPartida[sp.partida].push(sp);
});
// Crear partidas sintéticas para las huérfanas
const partidasSinteticas = Object.entries(huerfanasPorPartida).map(([partidaNum, subpartidas]) => {
// Generar código de partida (ej: grupo=3, subgrupo=4, partida=1 -> 34100)
const codigoPartida = `${grupoNum}${sg.subgrupo}${partidaNum}00`;
return {
objeto: codigoPartida,
desc_objeto: subpartidas[0]?.desc_objeto?.split(',')[0] || `Partida ${codigoPartida}`,
nivel: 'partida',
grupo: parseInt(grupoNum),
subgrupo: sg.subgrupo,
partida: parseInt(partidaNum),
_sintetica: true, // Marcar como sintética
subpartidas
};
});
// Asignar subpartidas a partidas existentes
const partidas = partidasUnicas.map(p => {
const subpartidas = allItems
.filter(item => item.nivel === 'subpartida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo && item.partida == p.partida)
.reduce((acc, item) => {
if (!acc.find(sp => sp.objeto === item.objeto)) {
acc.push(item);
}
return acc;
}, [])
const subpartidas = todasSubpartidas
.filter(item => item.partida == p.partida)
.sort((a, b) => a.objeto.localeCompare(b.objeto));
return { ...p, subpartidas };
});
return { ...sg, partidas };
// Combinar partidas existentes con sintéticas y ordenar
const todasPartidas = [...partidas, ...partidasSinteticas]
.sort((a, b) => a.objeto.localeCompare(b.objeto));
return { ...sg, partidas: todasPartidas };
});
return { subgrupos };
......@@ -794,23 +893,36 @@
}
function goToSearchResult(item) {
// Encontrar el grupo correspondiente
// Si es un grupo, simplemente seleccionarlo
if (item.nivel === 'grupo') {
selectGrupo(item);
return;
}
// Para otros niveles, encontrar y seleccionar el grupo padre
const grupoNum = item.grupo;
const targetGrupo = grupos.find(g => g.objeto.startsWith(grupoNum));
const targetGrupo = grupos.find(g => g.objeto === grupoNum + '0000');
if (targetGrupo) {
// Seleccionar el grupo primero
selectedGrupo = targetGrupo;
highlightedItem = item.objeto;
searchQuery = '';
sidebarOpen = false;
// Scroll al elemento después de un breve delay
// Esperar a que el contenido se renderice y luego hacer scroll
setTimeout(() => {
const prefix = item.nivel === 'subgrupo' ? 'sg' : item.nivel === 'partida' ? 'p' : 'sp';
const element = document.getElementById(`${prefix}-${item.objeto}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
// Quitar el highlight después de unos segundos
setTimeout(() => {
highlightedItem = null;
}, 3000);
}, 150);
}
}
......@@ -1072,7 +1184,7 @@
<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">
<div class="overflow-y-auto max-h-64" onscroll={(e) => handleEntityScroll(e, 'main')}>
{#each filteredEntities() as entity}
<button
class="w-full text-left px-3 py-2 text-sm"
......@@ -1088,6 +1200,11 @@
</div>
</button>
{/each}
{#if entityLimit < entities.length}
<div class="text-center py-2 text-xs" style="color: var(--theme-texto);">
Scroll para ver más ({entities.length - entityLimit} restantes)
</div>
{/if}
</div>
</div>
{/if}
......@@ -1133,7 +1250,7 @@
{#if loading}
<div class="flex items-center justify-center py-20">
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
<Spinner size={48} color="var(--theme-texto)" />
</div>
{:else}
{#if viewMode === 'lista'}
......@@ -1207,13 +1324,17 @@
<div class="space-y-1">
{#each searchResults as result}
<button
class="w-full text-left px-2 py-2 text-sm rounded transition-all"
style="color: var(--theme-titulo);"
class="search-result-item w-full text-left px-3 py-3 text-sm rounded-lg transition-all cursor-pointer"
onclick={() => goToSearchResult(result)}
>
<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>
<span class="text-xs block mb-0.5" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
<div class="flex items-baseline gap-2">
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.objeto}</span>
<span class="flex-1" style="color: var(--theme-titulo);">{result.desc_objeto}</span>
<svg class="w-4 h-4 flex-shrink-0 opacity-50" 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>
</div>
</button>
{/each}
{#if searchResults.length === 0}
......@@ -1333,10 +1454,14 @@
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-2 sm:gap-3">
<span class="font-mono text-xs pt-0.5 shrink-0" style="color: var(--theme-texto);">{partida.objeto}</span>
<span class="font-mono text-xs pt-0.5 shrink-0" style="color: var(--theme-texto); {partida._sintetica ? 'opacity: 0.5;' : ''}">{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._sintetica}
<span class="text-left opacity-60 italic">(Partida inferida)</span>
{:else}
<span class="text-left">{partida.desc_objeto}</span>
{/if}
{#if partida.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
......@@ -1346,18 +1471,20 @@
{partida.n_variaciones} var.
</button>
{/if}
<a
href="/objeto/{partida.objeto}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{#if !partida._sintetica}
<a
href="/objeto/{partida.objeto}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/if}
</h4>
{#if partidaDescs.length > 0}
{#if partidaDescs.length > 0 && !partida._sintetica}
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{partidaDescs[0].descripcion}</p>
{/if}
</div>
......@@ -1466,35 +1593,53 @@
<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}
<!-- Header con breadcrumb y botón de ayuda -->
<div class="flex items-center justify-between mb-2">
<!-- Breadcrumb de navegación (solo en modo jerárquico) -->
{#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 0}
<nav class="flex items-center gap-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="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>
{:else}
<div></div>
{/if}
<!-- Botón de ayuda -->
<button
onclick={() => showTreemapHelp = true}
class="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs transition-colors"
style="color: var(--theme-texto); background-color: var(--theme-fill);"
title="Cómo leer este gráfico"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="hidden sm:inline">Cómo leer</span>
</button>
</div>
<!-- Contenedor del Treemap -->
<div
......@@ -1782,7 +1927,7 @@
>
<span class="font-medium">Todo el Estado</span>
</button>
<div class="overflow-y-auto max-h-48">
<div class="overflow-y-auto max-h-64" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntitiesA() as entity}
<button
class="w-full text-left px-3 py-2 text-sm"
......@@ -1795,6 +1940,11 @@
</div>
</button>
{/each}
{#if compareALimit < entities.length}
<div class="text-center py-2 text-xs" style="color: var(--theme-texto);">
Scroll para ver más
</div>
{/if}
</div>
</div>
{/if}
......@@ -1880,7 +2030,7 @@
>
<span class="font-medium">Todo el Estado</span>
</button>
<div class="overflow-y-auto max-h-48">
<div class="overflow-y-auto max-h-64" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntitiesB() as entity}
<button
class="w-full text-left px-3 py-2 text-sm"
......@@ -1893,6 +2043,11 @@
</div>
</button>
{/each}
{#if compareBLimit < entities.length}
<div class="text-center py-2 text-xs" style="color: var(--theme-texto);">
Scroll para ver más
</div>
{/if}
</div>
</div>
{/if}
......@@ -1992,6 +2147,109 @@
</div>
{/if}
<!-- Modal de ayuda para el Treemap -->
{#if showTreemapHelp}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
onclick={() => showTreemapHelp = false}
role="dialog"
aria-modal="true"
>
<!-- Overlay -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<!-- Modal content -->
<div
class="relative w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden"
style="background-color: var(--theme-body);"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b" style="border-color: var(--theme-borde);">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background-color: var(--theme-fill);">
<svg class="w-5 h-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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 class="text-lg font-semibold" style="color: var(--theme-titulo);">Cómo leer este gráfico</h3>
</div>
<button
onclick={() => showTreemapHelp = false}
class="p-2 rounded-lg transition-colors"
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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-4" style="color: var(--theme-texto);">
<div class="flex gap-4">
<div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
<svg class="w-6 h-6" 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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</div>
<div>
<h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Tamaño = Proporción del gasto</h4>
<p class="text-sm leading-relaxed">Cada rectángulo representa una categoría de gasto. Cuanto más grande es el rectángulo, mayor es el monto gastado en esa categoría.</p>
</div>
</div>
<div class="flex gap-4">
<div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
<svg class="w-6 h-6" 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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<div>
<h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Colores por grupo</h4>
<p class="text-sm leading-relaxed">Cada color representa un grupo principal del clasificador. Las categorías del mismo grupo comparten el mismo color.</p>
</div>
</div>
<div class="flex gap-4">
<div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
<svg class="w-6 h-6" 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="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
</div>
<div>
<h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Hover para ver detalle</h4>
<p class="text-sm leading-relaxed">Pasa el cursor sobre cualquier rectángulo para ver el nombre de la categoría, el monto devengado y su porcentaje respecto al total.</p>
</div>
</div>
<div class="flex gap-4">
<div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
<svg class="w-6 h-6" 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
<div>
<h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Explorar (modo jerárquico)</h4>
<p class="text-sm leading-relaxed">Haz clic en <span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold mx-1" style="background-color: var(--theme-fill); border: 1px solid var(--theme-borde);">+</span> para entrar en un grupo y ver sus subcategorías en detalle.</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
<button
onclick={() => showTreemapHelp = false}
class="w-full py-2.5 rounded-lg font-medium transition-colors"
style="background-color: var(--theme-accent); color: white;"
>
Entendido
</button>
</div>
</div>
</div>
{/if}
<style>
/* Dropdown controls - tema oscuro por defecto */
.dropdown-control {
......@@ -2051,4 +2309,33 @@
opacity: 0;
transform: scale(0.95);
}
/* Search result items - más clickeables */
.search-result-item {
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
color: var(--theme-titulo);
}
.search-result-item:hover {
background: var(--theme-fill);
border-color: var(--theme-accent);
transform: translateX(4px);
}
.search-result-item:active {
transform: translateX(2px);
opacity: 0.9;
}
/* Tema claro */
:global(.light) .search-result-item,
:global(html:not(.dark)) .search-result-item {
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06);
}
:global(.light) .search-result-item:hover,
:global(html:not(.dark)) .search-result-item:hover {
background: rgba(0,0,0,0.03);
box-shadow: inset 0 0 0 1px var(--theme-accent);
}
</style>
......
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import Spinner from '$lib/components/ui/Spinner.svelte';
// Datos del servidor (reactivos)
let { data } = $props();
let objetoData = $derived(data.objeto);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
// Código y nivel del objeto desde datos del servidor (reactivos)
let objetoCodigo = $derived(objetoData.objeto);
let objetoNivel = $derived(objetoData.nivel);
const objeto = data.objeto;
const padres = data.padres;
const hijos = data.hijos;
// ══════════════════════════════════════════════════════════════
// ESTADO GENERAL
// ══════════════════════════════════════════════════════════════
let mounted = $state(false);
let loading = $state(true);
let hoveredYear = $state(null);
let showInfo = $state(false);
let vista = $state('agregado'); // 'agregado' | 'comparar'
let isDark = $state(true);
// Parsear descripciones del objeto
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = JSON.parse(descripcionesStr);
// Ordenar por año más reciente (extraer el máximo año de cada rango)
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA; // Descendente, más reciente primero
return maxYearB - maxYearA;
});
} catch {
return [];
......@@ -32,138 +52,2430 @@
return labels[nivel] || nivel;
}
const descripciones = parseDescripciones(objeto.descripciones);
let descripciones = $derived(parseDescripciones(objetoData.descripciones));
// Info del objeto (reactivo)
let objeto = $derived({
codigo: objetoData.objeto,
nombre: objetoData.desc_objeto,
nivel: getNivelLabel(objetoData.nivel),
años: objetoData.gestiones || '',
descripcion: descripciones.length > 0 ? descripciones[0].descripcion : '',
variaciones: descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion }))
});
// ══════════════════════════════════════════════════════════════
// ESTADO SELECTOR DE ENTIDAD
// ══════════════════════════════════════════════════════════════
let entidades = $state([]); // Lista de entidades disponibles
let selectedEntity = $state(null); // null = Todo el Estado
let entitySearchQuery = $state('');
let entityDropdownOpen = $state(false);
// Límites dinámicos para scroll infinito
let entityLimit = $state(30);
let entityLimitA = $state(30);
let entityLimitB = $state(30);
// Filtrar entidades por búsqueda
let filteredEntities = $derived.by(() => {
if (!entitySearchQuery || entitySearchQuery.length < 2) {
return entidades.slice(0, entityLimit);
}
const q = entitySearchQuery.toLowerCase();
return entidades
.filter(e =>
e.entidad_desc?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, entityLimit);
});
// Handler para cargar más entidades al hacer scroll
function handleEntityScroll(event, type) {
const el = event.target;
const threshold = 50;
if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
if (type === 'main' && entityLimit < entidades.length) {
entityLimit = Math.min(entityLimit + 30, entidades.length);
} else if (type === 'A' && entityLimitA < entidades.length) {
entityLimitA = Math.min(entityLimitA + 30, entidades.length);
} else if (type === 'B' && entityLimitB < entidades.length) {
entityLimitB = Math.min(entityLimitB + 30, entidades.length);
}
}
}
function selectEntity(entity) {
selectedEntity = entity;
entityDropdownOpen = false;
entitySearchQuery = '';
entityLimit = 30;
loadData();
}
function clearEntity() {
selectedEntity = null;
entityDropdownOpen = false;
entitySearchQuery = '';
entityLimit = 30;
loadData();
}
// ══════════════════════════════════════════════════════════════
// DATOS DE SUPABASE
// ══════════════════════════════════════════════════════════════
let datosAnuales = $state([]); // Datos por año
let datosHistorico = $state(null); // Fila con gestion=0
let topEntidadesPorAño = $state({}); // Top 3 por año (solo para Todo el Estado)
// Cargar datos según selección
async function loadData() {
loading = true;
if (selectedEntity) {
// Cargar desde vista_objeto_entidad
const { data, error } = await supabase
.schema('ppto')
.from('vista_objeto_entidad')
.select('*')
.eq('objeto', objetoCodigo)
.eq('nivel', objetoNivel)
.eq('entidad', selectedEntity.entidad)
.order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
topEntidadesPorAño = {}; // No hay top entidades cuando se filtra por entidad
}
} else {
// Cargar desde vista_objeto_estado (Todo el Estado)
const { data, error } = await supabase
.schema('ppto')
.from('vista_objeto_estado')
.select('*')
.eq('objeto', objetoCodigo)
.order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
// Construir top entidades por año
topEntidadesPorAño = {};
datosAnuales.forEach(d => {
topEntidadesPorAño[d.gestion] = [
{ nombre: d.top1_entidad, monto: d.top1_monto, porcentaje: d.top1_pct },
{ nombre: d.top2_entidad, monto: d.top2_monto, porcentaje: d.top2_pct },
{ nombre: d.top3_entidad, monto: d.top3_monto, porcentaje: d.top3_pct },
].filter(e => e.nombre);
});
}
}
loading = false;
}
// Cargar lista de entidades disponibles para este objeto
async function loadEntidades() {
const { data, error } = await supabase
.schema('ppto')
.from('entidades_por_objeto')
.select('entidad, entidad_desc')
.eq('objeto', objetoCodigo)
.eq('nivel', objetoNivel)
.order('entidad_desc');
if (data) {
entidades = data;
}
}
// ══════════════════════════════════════════════════════════════
// VALORES DERIVADOS
// ══════════════════════════════════════════════════════════════
// Datos para el gráfico (monto o per_capita según preferencia)
let gastoPerCapita = $derived(
datosAnuales.map(d => ({
año: d.gestion,
monto: selectedEntity ? d.monto : d.total,
perCapita: d.per_capita
}))
);
let maxPerCapita = $derived(
gastoPerCapita.length > 0 ? Math.max(...gastoPerCapita.map(g => g.perCapita)) : 1
);
// Histórico
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
let totalPorcentaje = $derived(datosHistorico?.prop || 0);
let ranking = $derived(datosHistorico?.ranking || 0);
let nPares = $derived(datosHistorico?.n_pares || 0);
let nEntidades = $derived(datosHistorico?.n_entidades || 0);
// Número de entidades - cambia según el año seleccionado y la entidad seleccionada
let totalEntidadesHistorico = $derived.by(() => {
if (selectedEntity && datosAnuales.length > 0) {
// Para entidad seleccionada: promedio de n_entidades de los años que tiene esta entidad
// Esto refleja cuántas entidades participaron durante el período de vida de esta entidad
const suma = datosAnuales.reduce((acc, d) => acc + (d.n_entidades || 0), 0);
return Math.round(suma / datosAnuales.length);
}
// Para "Todo el Estado": usar la lista del dropdown o el histórico
return entidades.length || nEntidades;
});
let totalEntidades = $derived.by(() => {
if (hoveredYear) {
// Buscar n_entidades del año específico
const añoData = datosAnuales.find(d => d.gestion === hoveredYear);
return añoData?.n_entidades || totalEntidadesHistorico;
}
return totalEntidadesHistorico;
});
// Para el ranking: valores reactivos según hover
let displayRankingValue = $derived.by(() => {
if (hoveredYear) {
const añoData = datosAnuales.find(d => d.gestion === hoveredYear);
return añoData?.ranking || ranking;
}
return ranking;
});
let rankingDenominador = $derived.by(() => {
if (selectedEntity) {
// Para entidad: usar n_entidades del año específico o del histórico
if (hoveredYear) {
const añoData = datosAnuales.find(d => d.gestion === hoveredYear);
return añoData?.n_entidades || nEntidades;
}
// Sin hover: usar el n_entidades del histórico (total de entidades únicas del período)
return nEntidades;
}
// Para "Todo el Estado": comparación entre objetos del mismo nivel
return nPares;
});
// Top entidades histórico (solo para Todo el Estado)
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad, monto: datosHistorico.top1_monto, porcentaje: datosHistorico.top1_pct },
{ nombre: datosHistorico.top2_entidad, monto: datosHistorico.top2_monto, porcentaje: datosHistorico.top2_pct },
{ nombre: datosHistorico.top3_entidad, monto: datosHistorico.top3_monto, porcentaje: datosHistorico.top3_pct },
].filter(e => e.nombre) : []
);
// Años disponibles
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
// Current view data (changes on hover)
let currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gasto en ${currentData.año}` : `gasto ${primerAño}-${ultimoAño}`);
let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
let displayPerCapitaLabel = $derived(currentData ? `por persona en ${currentData.año}` : 'por persona (promedio)');
let displayPorcentaje = $derived(
currentData && totalHistorico > 0
? (totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length))).toFixed(1)
: totalPorcentaje
);
let displayPorcentajeLabel = $derived(currentData ? `del gasto ${currentData.año}` : 'del gasto total');
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${displayRankingValue} de ${rankingDenominador}`);
let displayRankingLabel = $derived(
selectedEntity
? (currentData ? `entre las que más gastan en ${currentData.año}` : 'entre las que más gastan')
: (currentData ? `ranking en ${currentData.año}` : 'ranking entre grupos')
);
// Top entidades reactivo según hover
let displayTopEntidades = $derived(
hoveredYear && topEntidadesPorAño[hoveredYear]
? topEntidadesPorAño[hoveredYear]
: topEntidadesTotal
);
let displayTopLabel = $derived(hoveredYear ? `en ${hoveredYear}` : `${primerAño}-${ultimoAño}`);
// ══════════════════════════════════════════════════════════════
// TWEENED VALUES
// ══════════════════════════════════════════════════════════════
const tweenOptions = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tweenOptions);
const twPerCapita = tweened(0, tweenOptions);
const twPorcentaje = tweened(0, tweenOptions);
const twTop1 = tweened(0, tweenOptions);
const twTop2 = tweened(0, tweenOptions);
const twTop3 = tweened(0, tweenOptions);
// ══════════════════════════════════════════════════════════════
// COMPARACIÓN - Datos reales de Supabase
// ══════════════════════════════════════════════════════════════
const twMontoA = tweened(0, tweenOptions);
const twMontoB = tweened(0, tweenOptions);
const twPerCapitaA = tweened(0, tweenOptions);
const twPerCapitaB = tweened(0, tweenOptions);
let hoveredYearComparar = $state(null);
let loadingComparacion = $state(false);
// Entidades seleccionadas para comparar
let entidadCompararA = $state(null);
let entidadCompararB = $state(null);
// Datos cargados de cada entidad
let datosCompararA = $state({ anual: [], historico: null });
let datosCompararB = $state({ anual: [], historico: null });
// Dropdown states para comparación
let dropdownAOpen = $state(false);
let dropdownBOpen = $state(false);
let searchA = $state('');
let searchB = $state('');
// Filtrar entidades para cada dropdown
let filteredEntidadesA = $derived.by(() => {
if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
const q = searchA.toLowerCase();
return entidades.filter(e =>
e.entidad_desc?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
).slice(0, entityLimitA);
});
let filteredEntidadesB = $derived.by(() => {
if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
const q = searchB.toLowerCase();
return entidades.filter(e =>
e.entidad_desc?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
).slice(0, entityLimitB);
});
// Cargar datos de una entidad para comparación
async function loadDatosEntidad(entidad) {
const { data, error } = await supabase
.schema('ppto')
.from('vista_objeto_entidad')
.select('*')
.eq('objeto', objetoCodigo)
.eq('nivel', objetoNivel)
.eq('entidad', entidad.entidad)
.order('gestion');
if (data) {
return {
anual: data.filter(d => d.gestion > 0),
historico: data.find(d => d.gestion === 0) || null
};
}
return { anual: [], historico: null };
}
// Seleccionar entidad A
async function selectEntidadA(entidad) {
entidadCompararA = entidad;
dropdownAOpen = false;
searchA = '';
entityLimitA = 30;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entidad);
loadingComparacion = false;
}
// Seleccionar entidad B
async function selectEntidadB(entidad) {
entidadCompararB = entidad;
dropdownBOpen = false;
searchB = '';
entityLimitB = 30;
loadingComparacion = true;
datosCompararB = await loadDatosEntidad(entidad);
loadingComparacion = false;
}
// Selector de rango de años
let rangoAños = $state('10'); // '5', '10', '15', 'todo'
// Años comunes entre ambas entidades
let añosComunesTotales = $derived.by(() => {
if (!datosCompararA.anual.length || !datosCompararB.anual.length) return [];
const añosA = new Set(datosCompararA.anual.map(d => d.gestion));
const añosB = new Set(datosCompararB.anual.map(d => d.gestion));
return [...añosA].filter(a => añosB.has(a)).sort((a, b) => a - b);
});
// Años filtrados según el rango seleccionado
let añosComparacion = $derived.by(() => {
if (rangoAños === 'todo') return añosComunesTotales;
const n = parseInt(rangoAños);
return añosComunesTotales.slice(-n);
});
// Datos formateados para el gráfico
let datosGraficoA = $derived.by(() => {
const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
let datosGraficoB = $derived.by(() => {
const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
// Máximo para escala del gráfico
let maxComparacion = $derived.by(() => {
const allPerCapita = [
...datosGraficoA.map(d => d.per_capita || 0),
...datosGraficoB.map(d => d.per_capita || 0)
];
return Math.max(...allPerCapita, 1) * 1.1;
});
// Período para mostrar
let displayPeriodoComparar = $derived.by(() => {
if (hoveredYearComparar) return hoveredYearComparar;
if (añosComparacion.length > 0) {
return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
}
return 'Selecciona entidades';
});
// Valores reactivos para comparación (peso y ranking)
let displayPropA = $derived.by(() => {
if (hoveredYearComparar) {
const idx = añosComparacion.indexOf(hoveredYearComparar);
if (idx >= 0) return datosGraficoA[idx]?.prop || 0;
}
return datosCompararA.historico?.prop || 0;
});
let displayPropB = $derived.by(() => {
if (hoveredYearComparar) {
const idx = añosComparacion.indexOf(hoveredYearComparar);
if (idx >= 0) return datosGraficoB[idx]?.prop || 0;
}
return datosCompararB.historico?.prop || 0;
});
let displayRankingA = $derived.by(() => {
if (hoveredYearComparar) {
const idx = añosComparacion.indexOf(hoveredYearComparar);
if (idx >= 0) return datosGraficoA[idx]?.ranking || '-';
}
return datosCompararA.historico?.ranking || '-';
});
let displayRankingB = $derived.by(() => {
if (hoveredYearComparar) {
const idx = añosComparacion.indexOf(hoveredYearComparar);
if (idx >= 0) return datosGraficoB[idx]?.ranking || '-';
}
return datosCompararB.historico?.ranking || '-';
});
let displayNEntidadesA = $derived.by(() => {
if (hoveredYearComparar) {
const idx = añosComparacion.indexOf(hoveredYearComparar);
if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-';
}
return datosCompararA.historico?.n_entidades || '-';
});
let displayNEntidadesB = $derived.by(() => {
if (hoveredYearComparar) {
const idx = añosComparacion.indexOf(hoveredYearComparar);
if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-';
}
return datosCompararB.historico?.n_entidades || '-';
});
// Colores
let colorA = '#2a9d8f';
let colorB = '#f4a261';
// Actualizar tweened de comparación
$effect(() => {
const year = hoveredYearComparar;
const idx = year ? añosComparacion.indexOf(year) : -1;
let montoA, montoB, perCapitaA, perCapitaB;
if (idx >= 0) {
montoA = toNumber(datosGraficoA[idx]?.monto);
montoB = toNumber(datosGraficoB[idx]?.monto);
perCapitaA = toNumber(datosGraficoA[idx]?.per_capita);
perCapitaB = toNumber(datosGraficoB[idx]?.per_capita);
} else {
// Histórico o suma
montoA = toNumber(datosCompararA.historico?.monto);
montoB = toNumber(datosCompararB.historico?.monto);
perCapitaA = toNumber(datosCompararA.historico?.per_capita);
perCapitaB = toNumber(datosCompararB.historico?.per_capita);
}
twMontoA.set(montoA);
twMontoB.set(montoB);
twPerCapitaA.set(perCapitaA);
twPerCapitaB.set(perCapitaB);
});
// Helper para asegurar que siempre tengamos un número
function toNumber(val) {
const n = parseFloat(val);
return isNaN(n) ? 0 : n;
}
// Actualizar tweened cuando cambian los valores
// Leemos TODAS las dependencias explícitamente para que el effect se re-ejecute
$effect(() => {
// Leer todas las dependencias primero (para que Svelte las trackee)
const year = hoveredYear;
const datos = gastoPerCapita; // Leer siempre
const histTotal = toNumber(totalHistorico);
const histPerCapita = toNumber(promedioPerCapita);
const histPorcentaje = toNumber(totalPorcentaje);
// Calcular valores
const data = year ? datos.find(g => g.año === year) : null;
const monto = toNumber(data ? data.monto : histTotal);
const perCapita = toNumber(data ? data.perCapita : histPerCapita);
const porcentaje = data && histTotal > 0
? toNumber((histPorcentaje * (data.monto / (histTotal / datos.length))).toFixed(1))
: histPorcentaje;
twMonto.set(monto);
twPerCapita.set(perCapita);
twPorcentaje.set(porcentaje);
});
$effect(() => {
// Leer todas las dependencias primero
const year = hoveredYear;
const topsPorAño = topEntidadesPorAño;
const topsTotal = topEntidadesTotal;
const tops = year && topsPorAño[year] ? topsPorAño[year] : topsTotal;
twTop1.set(toNumber(tops?.[0]?.porcentaje));
twTop2.set(toNumber(tops?.[1]?.porcentaje));
twTop3.set(toNumber(tops?.[2]?.porcentaje));
});
// ══════════════════════════════════════════════════════════════
// MOUNT
// ══════════════════════════════════════════════════════════════
onMount(async () => {
setTimeout(() => { mounted = true; }, 50);
// Leer estado del tema
isDark = document.documentElement.classList.contains('dark');
// Observar cambios de tema
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// Cargar datos
await Promise.all([loadData(), loadEntidades()]);
// Cerrar dropdown al hacer click fuera
const handleClickOutside = (e) => {
if (entityDropdownOpen && !e.target.closest('.entity-dropdown-container')) {
entityDropdownOpen = false;
}
// Dropdowns de comparación
if (dropdownAOpen && !e.target.closest('.comparador-dropdown-container')) {
dropdownAOpen = false;
}
if (dropdownBOpen && !e.target.closest('.comparador-dropdown-container')) {
dropdownBOpen = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => {
observer.disconnect();
document.removeEventListener('click', handleClickOutside);
};
});
// Recargar datos cuando cambia el objeto (navegación interna)
let previousCodigo = objetoCodigo;
$effect(() => {
if (mounted && objetoCodigo !== previousCodigo) {
previousCodigo = objetoCodigo;
// Resetear estado
loading = true;
selectedEntity = null;
entidadCompararA = null;
entidadCompararB = null;
datosCompararA = { anual: [], historico: null };
datosCompararB = { anual: [], historico: null };
vista = 'agregado';
// Recargar datos
loadData();
}
});
// ══════════════════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════════════════
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toFixed(1) + 'MM';
if (m >= 1e6) return (m / 1e6).toFixed(1) + 'M';
if (m >= 1e3) return Math.round(m / 1e3) + 'K';
return m?.toLocaleString() || '0';
}
function formatMontoLargo(m) {
if (m >= 1e9) return Math.round(m / 1e9).toLocaleString('es-BO') + ' mil Mill.';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' Mill.';
return m?.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0';
}
function formatPerCapita(n) {
if (n >= 1000) return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
if (n >= 10) return n.toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
if (n < 1) return n.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return n.toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
}
</script>
<svelte:head>
<title>{objeto.desc_objeto} | Presupuesto Público</title>
<title>{objeto.nombre} | 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 class="border-b bg-slate-50">
<div class="max-w-4xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span>/</span>
<a href="/clasificadores/objeto-gasto" class="hover:text-slate-700">Objeto del Gasto</a>
{#each padres as padre}
<span>/</span>
<a href="/objeto/{padre.objeto}" class="hover:text-slate-700">{padre.desc_objeto}</a>
{/each}
<span>/</span>
<span class="text-slate-900">{objeto.desc_objeto}</span>
</nav>
</div>
</header>
<div class="page" class:mounted class:tema-claro={!isDark}>
<main class="max-w-4xl mx-auto px-6 py-8">
<!-- Encabezado -->
<div class="mb-8">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(objeto.nivel)}</p>
<div class="flex items-baseline gap-4">
<span class="font-mono text-2xl text-slate-400">{objeto.objeto}</span>
<h1 class="text-2xl font-medium text-slate-900">{objeto.desc_objeto}</h1>
<!-- Header compacto -->
<header>
<nav class="breadcrumb">
<a href="/clasificadores/objeto-gasto">← Objeto del Gasto</a>
{#each padres as padre}
<span class="breadcrumb-sep">/</span>
<a href="/objeto/{padre.objeto}" class="breadcrumb-link">{padre.desc_objeto}</a>
{/each}
</nav>
<div class="title-row">
<div class="title-main">
<span class="codigo">{objeto.codigo}</span>
<h1>
{objeto.nombre}
<button class="help-icon" onclick={() => showInfo = !showInfo} class:active={showInfo}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M9.5 9a3 3 0 1 1 3.5 2.95c-.5.17-1 .62-1 1.3V15"/>
<circle cx="12" cy="18" r="0.5" fill="currentColor"/>
</svg>
</button>
</h1>
</div>
</div>
<!-- Descripciones -->
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{#if descripciones.length > 1}
Descripciones ({descripciones.length} variaciones)
{:else}
Descripción
<div class="meta">
<span class="badge">{objeto.nivel}</span>
<span class="years">{objeto.años}</span>
</div>
</header>
<!-- Panel de info (colapsable) -->
{#if showInfo}
<div class="info-panel">
<div class="info-content">
<h4>Definición vigente</h4>
<p>{objeto.descripcion}</p>
{#if objeto.variaciones.length > 1}
<h4>Variaciones históricas</h4>
{#each objeto.variaciones as v}
<div class="variacion">
<span class="variacion-rango">{v.rango}</span>
<p>{v.texto}</p>
</div>
{/each}
{/if}
</h2>
<div class="space-y-4">
{#each descripciones as desc, i}
<div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200 bg-slate-50/50'} pl-4 py-3 rounded-r">
<p class="text-xs text-slate-500 mb-2">
{#if i === 0 && descripciones.length > 1}
<span class="text-blue-600 font-medium">Vigente</span>
<span class="mx-1">·</span>
{/if}
{desc.rangos}
</p>
<p class="text-slate-700 leading-relaxed">{desc.descripcion}</p>
{#if hijos.length > 0}
<h4>
{#if objetoNivel === 'grupo'}
Subgrupos ({hijos.length})
{:else if objetoNivel === 'subgrupo'}
Partidas ({hijos.length})
{:else if objetoNivel === 'partida'}
Subpartidas ({hijos.length})
{/if}
</h4>
<div class="hijos-list">
{#each hijos as hijo}
<a href="/objeto/{hijo.objeto}" class="hijo-link">
<span class="hijo-codigo">{hijo.objeto}</span>
<span class="hijo-nombre">{hijo.desc_objeto}</span>
</a>
{/each}
</div>
{/each}
{/if}
</div>
</section>
<!-- Jerarquía -->
{#if padres.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2>
<div class="space-y-2">
{#each padres as padre, i}
<div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem">
<span class="text-slate-300">└</span>
<a
href="/objeto/{padre.objeto}"
class="text-sm text-slate-600 hover:text-blue-600"
</div>
{/if}
<!-- Dashboard principal -->
<main>
<!-- Toggle de vista -->
<div class="vista-toggle">
<button
class="toggle-btn"
class:active={vista === 'agregado'}
onclick={() => vista = 'agregado'}
>
Agregado
</button>
<button
class="toggle-btn"
class:active={vista === 'comparar'}
onclick={() => vista = 'comparar'}
>
Comparar
</button>
</div>
<!-- Selector de entidad (solo en vista agregado) -->
{#if vista === 'agregado'}
<div class="entity-selector entity-dropdown-container">
<span class="selector-label">Viendo</span>
<div class="relative">
<button
class="selector-btn"
onclick={() => entityDropdownOpen = !entityDropdownOpen}
>
<span class="truncate">
{#if selectedEntity}
{selectedEntity.entidad_desc?.slice(0, 30)}{selectedEntity.entidad_desc?.length > 30 ? '...' : ''}
{:else}
Todo el sector público
{/if}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if entityDropdownOpen}
<div class="dropdown-panel">
<div class="dropdown-search">
<input
type="text"
bind:value={entitySearchQuery}
placeholder="Buscar entidad..."
class="search-input"
/>
</div>
<button
class="dropdown-item"
class:active={!selectedEntity}
onclick={() => clearEntity()}
>
<span class="font-mono text-xs text-slate-400">{padre.objeto}</span>
{padre.desc_objeto}
</a>
<span class="font-medium">Todo el sector público</span>
<span class="text-xs opacity-60 ml-2">({nEntidades} entidades)</span>
</button>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'main')}>
{#each filteredEntities as entity}
<button
class="dropdown-item"
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => selectEntity(entity)}
>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
{#if entityLimit < entidades.length}
<div class="text-center py-2 text-xs opacity-60">
Scroll para ver más ({entidades.length - entityLimit} restantes)
</div>
{/if}
</div>
</div>
{/each}
<div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem">
<span class="text-blue-500">└</span>
<span class="text-sm font-medium text-blue-600">
<span class="font-mono text-xs">{objeto.objeto}</span>
{objeto.desc_objeto}
</span>
</div>
{/if}
</div>
</section>
<span class="interaction-hint desktop-hint">Pasa el cursor sobre las barras para ver valores por año</span>
<span class="interaction-hint mobile-hint">Toca las barras para ver valores por año</span>
</div>
{/if}
<!-- Hijos -->
{#if hijos.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{#if objeto.nivel === 'grupo'}
Subgrupos ({hijos.length})
{:else if objeto.nivel === 'subgrupo'}
Partidas ({hijos.length})
{:else if objeto.nivel === 'partida'}
Subpartidas ({hijos.length})
{/if}
</h2>
<div class="border rounded-lg divide-y">
{#each hijos as hijo}
{@const hijoDescs = parseDescripciones(hijo.descripciones)}
<a
href="/objeto/{hijo.objeto}"
class="block px-4 py-3 hover:bg-slate-50 transition-colors"
>
<div class="flex items-baseline gap-3">
<span class="font-mono text-sm text-slate-400">{hijo.objeto}</span>
<span class="text-slate-900">{hijo.desc_objeto}</span>
{#if hijo.n_variaciones > 1}
<span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span>
{/if}
{#if vista === 'agregado'}
<!-- VISTA AGREGADO -->
<!-- KPIs arriba -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">Bs {formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">Bs {formatPerCapita($twPerCapita)}</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
<!-- Gráfico de barras (per cápita) - protagonista al medio -->
<div class="chart-section chart-protagonista">
<div class="chart-header">
<span class="chart-title">Gasto por persona</span>
<span class="chart-period">{displayPeriodo}</span>
</div>
<div class="chart-wrapper">
<!-- Y-axis labels -->
<div class="y-axis">
<span class="y-label">Bs {formatMonto(maxPerCapita)}</span>
<span class="y-label">Bs {formatMonto(Math.round(maxPerCapita / 2))}</span>
<span class="y-label">Bs 0</span>
</div>
<div
class="chart"
onmouseleave={() => hoveredYear = null}
role="group"
>
{#each gastoPerCapita as g}
<div
class="bar-container"
onmouseenter={() => hoveredYear = g.año}
role="button"
tabindex="0"
>
<div
class="bar"
class:hovered={hoveredYear === g.año}
style="height: {(g.perCapita / maxPerCapita) * 100}%"
></div>
<span class="bar-label" class:visible={hoveredYear === g.año || g.año % 5 === 0}>
{g.año.toString().slice(-2)}
</span>
</div>
{#if hijoDescs.length > 0}
<p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p>
{/if}
</a>
{/each}
{/each}
</div>
</div>
</div>
<!-- Top entidades (solo visible cuando es "Todo el Estado") -->
{#if !selectedEntity}
<div class="top-section">
<div class="top-header">
<h3>Mayores ejecutores</h3>
<span class="top-context">{totalEntidades} entidades · {displayTopLabel}</span>
</div>
<div class="top-list">
{#each displayTopEntidades as ent, i (ent.nombre)}
<div class="top-item">
<span class="top-rank">#{i + 1}</span>
<div class="top-info">
<span class="top-name">{ent.nombre}</span>
<div class="top-bar-bg">
<div class="top-bar" style="width: {((i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3) / $twTop1) * 100}%"></div>
</div>
</div>
<span class="top-pct">{(i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3).toFixed(1)}%</span>
</div>
{/each}
</div>
</div>
{/if}
{:else}
<!-- VISTA COMPARAR -->
<!-- Selectores de entidades con búsqueda -->
<div class="comparador-selectors">
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorA}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
<span class="truncate">
{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if dropdownAOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search">
<input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" />
</div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntidadesA as entity}
<button
class="dropdown-item"
class:active={entidadCompararA?.entidad === entity.entidad}
disabled={entidadCompararB?.entidad === entity.entidad}
onclick={() => selectEntidadA(entity)}
>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
{#if entityLimitA < entidades.length}
<div class="text-center py-2 text-xs opacity-60">
Scroll para ver más
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
<span class="vs">vs</span>
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorB}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
<span class="truncate">
{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if dropdownBOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search">
<input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" />
</div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntidadesB as entity}
<button
class="dropdown-item"
class:active={entidadCompararB?.entidad === entity.entidad}
disabled={entidadCompararA?.entidad === entity.entidad}
onclick={() => selectEntidadB(entity)}
>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
{#if entityLimitB < entidades.length}
<div class="text-center py-2 text-xs opacity-60">
Scroll para ver más
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Mensaje si no hay entidades seleccionadas -->
{#if !entidadCompararA || !entidadCompararB}
<div class="comparador-placeholder">
<p>Selecciona dos entidades para comparar su gasto en <strong>{objeto.nombre}</strong></p>
</div>
{:else if loadingComparacion}
<div class="comparador-placeholder">
<Spinner size={48} color="var(--theme-texto, #5A5650)" />
</div>
{:else if añosComparacion.length === 0}
<div class="comparador-placeholder">
<p>Las entidades seleccionadas no tienen años en común para este objeto.</p>
</div>
{:else}
<!-- Selector de rango de años -->
{#if añosComunesTotales.length > 10}
<div class="rango-selector">
<span class="rango-label">Mostrar:</span>
<div class="rango-options">
<button class="rango-btn" class:active={rangoAños === '5'} onclick={() => rangoAños = '5'}>5 años</button>
<button class="rango-btn" class:active={rangoAños === '10'} onclick={() => rangoAños = '10'}>10 años</button>
<button class="rango-btn" class:active={rangoAños === '15'} onclick={() => rangoAños = '15'}>15 años</button>
<button class="rango-btn" class:active={rangoAños === 'todo'} onclick={() => rangoAños = 'todo'}>Todo ({añosComunesTotales.length})</button>
</div>
</div>
{/if}
<!-- Gráfico comparativo -->
<div class="chart-section">
<div class="chart-header">
<span class="chart-title">Gasto por persona</span>
<span class="chart-period">{displayPeriodoComparar}</span>
</div>
<div class="chart-wrapper chart-wrapper-scroll">
<div class="y-axis y-axis-sticky">
<span class="y-label">Bs {formatMonto(Math.round(maxComparacion))}</span>
<span class="y-label">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
<span class="y-label">Bs 0</span>
</div>
<div
class="chart chart-comparar"
class:chart-muchos-años={añosComparacion.length > 15}
onmouseleave={() => hoveredYearComparar = null}
role="group"
>
{#each añosComparacion as año, i}
<div
class="bar-group"
class:active={hoveredYearComparar === año}
onmouseenter={() => hoveredYearComparar = año}
role="button"
tabindex="0"
>
<div class="bars-pair">
<div
class="bar bar-a"
style="height: {((datosGraficoA[i]?.per_capita || 0) / maxComparacion) * 100}%; --bar-color: {colorA}"
></div>
<div
class="bar bar-b"
style="height: {((datosGraficoB[i]?.per_capita || 0) / maxComparacion) * 100}%; --bar-color: {colorB}"
></div>
</div>
<span class="bar-label visible" class:highlight={hoveredYearComparar === año}>{año}</span>
</div>
{/each}
</div>
</div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>
<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>
</div>
</div>
<!-- KPIs comparativos - tabla -->
<div class="kpis-tabla">
<!-- Header -->
<div class="tabla-header">
<span class="tabla-label"></span>
<span class="tabla-val" style="color: {colorA}">{entidadCompararA.entidad_desc?.slice(0, 20)}{entidadCompararA.entidad_desc?.length > 20 ? '...' : ''}</span>
<span class="tabla-val" style="color: {colorB}">{entidadCompararB.entidad_desc?.slice(0, 20)}{entidadCompararB.entidad_desc?.length > 20 ? '...' : ''}</span>
</div>
<!-- Gasto -->
<div class="tabla-row">
<span class="tabla-label">{hoveredYearComparar ? `Gasto ${hoveredYearComparar}` : `Gasto ${añosComparacion[0]}-${añosComparacion[añosComparacion.length-1]}`}</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
</div>
<!-- Per cápita -->
<div class="tabla-row">
<span class="tabla-label">{hoveredYearComparar ? `Por persona ${hoveredYearComparar}` : 'Por persona (prom.)'}</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
</div>
<!-- Porcentaje -->
<div class="tabla-row">
<span class="tabla-label">{hoveredYearComparar ? `Peso en ${hoveredYearComparar}` : 'Peso sobre total'}</span>
<span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
</div>
<!-- Ranking -->
<div class="tabla-row">
<span class="tabla-label">{hoveredYearComparar ? `Ranking ${hoveredYearComparar}` : 'Ranking'}</span>
<span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
<span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
</div>
</div>
</section>
{/if}
{/if}
<!-- Volver -->
<div class="pt-6 border-t">
<a
href="/clasificadores/objeto-gasto"
class="text-sm text-blue-600 hover:underline"
>
Volver al clasificador
</a>
</div>
</main>
</div>
<style>
.page {
min-height: 100vh;
background: #0A0A0A;
color: #F5F0E8;
font-family: 'Instrument Sans', -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.mounted { opacity: 1; }
/* Header */
header {
padding: 1rem 2rem;
border-bottom: 1px solid #1E1E1C;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.breadcrumb a {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb a:hover { color: #F5F0E8; }
.breadcrumb-sep {
color: #3A3A38;
font-size: 0.75rem;
}
.breadcrumb-link {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-top: 0.5rem;
gap: 1rem;
}
.title-main {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.codigo {
font-family: 'DM Mono', monospace;
font-size: 1.5rem;
color: #5A5650;
font-weight: 400;
}
h1 {
font-family: 'DM Serif Display', Georgia, serif;
font-size: 1.5rem;
font-weight: 400;
color: #F5F0E8;
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.help-icon {
background: transparent;
border: none;
padding: 0;
color: #5A5650;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.help-icon:hover, .help-icon.active {
color: #E8C547;
}
.meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
}
.badge {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.25rem 0.5rem;
background: #1A1A18;
border: 1px solid #2E2E2C;
border-radius: 4px;
color: #8A8578;
}
.years {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
}
/* Info panel */
.info-panel {
background: #111110;
border-bottom: 1px solid #1E1E1C;
padding: 1.5rem 2rem;
}
.info-content h4 {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #5A5650;
margin: 0 0 0.5rem 0;
}
.info-content p {
font-size: 0.875rem;
line-height: 1.6;
color: #8A8578;
margin: 0 0 1.25rem 0;
}
.variacion {
padding-left: 1rem;
border-left: 2px solid #2E2E2C;
margin-bottom: 1rem;
}
.variacion-rango {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
}
.variacion p {
margin-top: 0.25rem;
font-size: 0.813rem;
}
/* Hijos list in info panel */
.hijos-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
.hijo-link {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: 4px;
text-decoration: none;
transition: background 0.2s;
}
.hijo-link:hover {
background: rgba(255, 255, 255, 0.05);
}
.hijo-codigo {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
min-width: 3rem;
}
.hijo-nombre {
font-size: 0.813rem;
color: #B0A89C;
}
.hijo-link:hover .hijo-nombre {
color: #F5F0E8;
}
/* Main */
main {
padding: 1rem 2rem;
max-width: 900px;
margin: 0 auto;
}
/* Entity selector */
.entity-selector {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.interaction-hint {
font-size: 0.688rem;
color: #5A5650;
font-style: italic;
margin-left: auto;
}
.desktop-hint {
display: inline;
}
.mobile-hint {
display: none;
}
@media (max-width: 640px) {
.desktop-hint {
display: none;
}
.mobile-hint {
display: inline;
}
}
.selector-label {
font-size: 0.813rem;
color: #5A5650;
}
.selector-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 8px;
color: #F5F0E8;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.selector-btn:hover {
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.15);
}
.selector-btn svg {
color: #5A5650;
}
.relative {
position: relative;
}
.dropdown-panel {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
width: min(500px, 90vw);
max-height: 360px;
overflow: hidden;
background: #1A1A18;
border: 1px solid #2E2E2C;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
z-index: 50;
}
.dropdown-search {
padding: 0.5rem;
border-bottom: 1px solid #2E2E2C;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: #111110;
border: 1px solid #2E2E2C;
border-radius: 6px;
color: #F5F0E8;
font-size: 0.875rem;
}
.search-input:focus {
outline: none;
border-color: #E8C547;
}
.search-input::placeholder {
color: #5A5650;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: transparent;
border: none;
color: #F5F0E8;
font-size: 0.813rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover {
background: rgba(255,255,255,0.05);
}
.dropdown-item.active {
background: rgba(232,197,71,0.15);
}
.dropdown-list {
max-height: 240px;
overflow-y: auto;
}
.entity-code {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
min-width: 2.5rem;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Chart */
.chart-section {
margin-bottom: 1rem;
}
.chart-protagonista {
margin-bottom: 1.25rem;
}
.chart-protagonista .chart {
height: calc(100vh - 480px);
min-height: 120px;
max-height: 200px;
}
.chart-protagonista .y-axis {
height: calc(100vh - 480px);
min-height: 120px;
max-height: 200px;
}
.chart-section:has(.chart-comparar) {
margin-bottom: 0;
}
.chart-section:has(.chart-comparar) .y-axis {
height: calc(100vh - 380px);
min-height: 140px;
max-height: 280px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.chart-title {
font-size: 0.75rem;
color: #5A5650;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.chart-period {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: #B0A89C;
font-variant-numeric: tabular-nums;
min-width: 5rem;
text-align: right;
}
.chart-wrapper {
display: flex;
gap: 0.75rem;
}
.chart-wrapper-scroll {
overflow-x: auto;
overflow-y: visible;
scrollbar-width: thin;
scrollbar-color: #2E2E2C transparent;
}
.chart-wrapper-scroll::-webkit-scrollbar {
height: 6px;
}
.chart-wrapper-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chart-wrapper-scroll::-webkit-scrollbar-thumb {
background: #2E2E2C;
border-radius: 3px;
}
.y-axis-sticky {
position: sticky;
left: 0;
z-index: 10;
background: #0A0A0A;
padding-right: 0.5rem;
margin-right: -0.5rem;
}
.chart-muchos-años {
min-width: max-content;
}
.chart-muchos-años .bar-group {
min-width: 40px;
}
.y-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 160px;
padding-bottom: 1.5rem;
}
.y-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: #5A5650;
}
.chart {
flex: 1;
display: flex;
align-items: flex-end;
gap: 4px;
height: 160px;
padding: 0 0.5rem;
border-left: 1px solid #2E2E2C;
}
.bar-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
cursor: pointer;
}
.bar {
width: 100%;
background: #2a9d8f;
border-radius: 4px 4px 0 0;
transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 4px;
}
.bar.hovered {
background: #E8C547;
transform: scaleX(1.1);
}
.bar-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: #B0A89C;
margin-top: 0.5rem;
opacity: 0;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.25s ease;
}
.bar-label.visible { opacity: 1; }
/* KPIs */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.kpi {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
padding: 0.75rem 0.75rem;
text-align: center;
border-radius: 8px;
transition: box-shadow 0.3s ease;
}
.kpi-value {
display: block;
font-family: 'DM Mono', monospace;
font-size: 1.125rem;
font-weight: 500;
color: #F5F0E8;
margin-bottom: 0;
transition: opacity 0.2s ease;
font-variant-numeric: tabular-nums;
min-height: 1.5rem;
}
.kpi-label {
font-size: 0.5625rem;
color: #5A5650;
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1.3;
transition: opacity 0.2s ease;
min-height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Top entidades */
.top-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.625rem;
}
.top-section h3 {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0;
}
.top-context {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
font-variant-numeric: tabular-nums;
}
.top-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.top-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 8px;
transition: box-shadow 0.25s ease;
}
.top-item:hover {
box-shadow: inset 0 0 0 1px rgba(201, 167, 81, 0.4);
}
.top-rank {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: #5A5650;
width: 24px;
}
.top-info {
flex: 1;
}
.top-name {
display: block;
font-size: 0.8125rem;
color: #F5F0E8;
margin-bottom: 0.375rem;
}
.top-bar-bg {
height: 4px;
background: #1E1E1C;
border-radius: 2px;
overflow: hidden;
}
.top-bar {
height: 100%;
background: #C9A751;
border-radius: 2px;
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), background 0.25s ease;
}
.top-item:hover .top-bar {
background: #E8C547;
}
.top-pct {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: #B0A89C;
min-width: 52px;
width: 52px;
text-align: right;
transition: opacity 0.2s ease;
font-variant-numeric: tabular-nums;
}
.see-all {
background: transparent;
border: 1px solid #2E2E2C;
border-radius: 8px;
padding: 0.75rem 1rem;
color: #8A8578;
font-size: 0.813rem;
cursor: pointer;
transition: all 0.2s;
width: 100%;
margin-top: 1rem;
}
.see-all:hover {
border-color: #E8C547;
color: #E8C547;
}
/* Vista Toggle */
.vista-toggle {
display: inline-flex;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 10px;
padding: 4px;
margin-bottom: 0.75rem;
}
.toggle-btn {
padding: 0.5rem 1.25rem;
font-size: 0.813rem;
font-weight: 500;
color: #5A5650;
background: transparent;
border: none;
border-radius: 7px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
color: #8A8578;
}
.toggle-btn.active {
background: #F5F0E8;
color: #0A0A0A;
}
/* Comparador inline - ajustado para 100vh */
.comparador-selectors {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.comparador-dropdown-container {
position: relative;
}
.comparador-btn {
width: 100%;
justify-content: space-between;
}
.comparador-panel {
width: 100%;
min-width: 280px;
}
.comparador-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
background: rgba(255,255,255,0.02);
border: 1px dashed #2E2E2C;
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.comparador-placeholder p {
color: #5A5650;
font-size: 0.875rem;
margin: 0;
}
.comparador-placeholder strong {
color: #8A8578;
}
.rango-selector {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.rango-label {
font-size: 0.75rem;
color: #5A5650;
}
.rango-options {
display: flex;
gap: 0.25rem;
}
.rango-btn {
padding: 0.375rem 0.75rem;
font-size: 0.6875rem;
font-family: 'DM Mono', monospace;
color: #5A5650;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.rango-btn:hover {
color: #8A8578;
background: rgba(255,255,255,0.05);
}
.rango-btn.active {
color: #F5F0E8;
background: rgba(255,255,255,0.1);
}
.dropdown-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.selector-group {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.selector-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.selector-group select {
flex: 1;
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 8px;
padding: 0.5rem 0.75rem;
color: #F5F0E8;
font-size: 0.813rem;
font-family: 'Instrument Sans', sans-serif;
cursor: pointer;
}
.selector-group select:focus {
outline: none;
border-color: #E8C547;
}
.vs {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
flex-shrink: 0;
}
.chart-comparar {
gap: 1.5rem;
justify-content: center;
height: calc(100vh - 380px);
min-height: 140px;
max-height: 280px;
}
.chart-comparar + .chart-legend + .kpis-comparar {
margin-top: 0;
}
.bar-group {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.bars-pair {
display: flex;
align-items: flex-end;
gap: 4px;
flex: 1;
}
.bar-a, .bar-b {
width: 20px;
border-radius: 4px 4px 0 0;
min-height: 4px;
background: var(--bar-color);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), filter 0.25s ease;
}
.bar-group.active .bar-a,
.bar-group.active .bar-b {
filter: brightness(1.15);
}
.bar-group {
cursor: pointer;
padding: 0.5rem 0.35rem;
margin: -0.5rem -0.35rem;
border-radius: 6px;
transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.bar-group.active {
background: rgba(42, 157, 143, 0.15);
}
.bar-label.highlight {
color: #F5F0E8;
font-weight: 500;
transition: color 0.25s ease;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: #8A8578;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 3px;
}
/* KPIs comparativos - tabla */
.kpis-tabla {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 10px;
padding: 0.5rem 1rem;
}
.tabla-header {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid #2E2E2C;
margin-bottom: 0.375rem;
}
.tabla-header .tabla-val {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
letter-spacing: 0.05em;
text-transform: uppercase;
text-align: center;
}
.tabla-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
padding: 0.25rem 0;
}
.tabla-label {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
color: #8A8578;
display: flex;
align-items: center;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tabla-val {
font-family: 'DM Mono', monospace;
font-size: 0.9375rem;
font-weight: 500;
color: #F5F0E8;
text-align: center;
font-variant-numeric: tabular-nums;
transition: opacity 0.2s ease;
}
/* Insight comparador */
.comparador-insight {
text-align: center;
padding: 1rem 1.5rem;
background: linear-gradient(90deg, rgba(232,197,71,0.05), rgba(74,139,110,0.05));
border-radius: 10px;
border: 1px solid #2E2E2C;
font-size: 0.875rem;
color: #8A8578;
line-height: 1.6;
}
.comparador-insight strong {
color: #F5F0E8;
}
.insight-year {
font-family: 'DM Mono', monospace;
font-size: 0.813rem;
color: #E8C547;
}
/* Responsive */
@media (max-width: 640px) {
header, main {
padding: 1.25rem 1rem;
}
.title-main {
flex-direction: column;
gap: 0.25rem;
}
.codigo {
font-size: 1.25rem;
}
h1 {
font-size: 1.375rem;
}
.y-axis {
display: none;
}
.chart {
border-left: none;
height: 120px;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
.kpi-value {
font-size: 1.125rem;
}
.top-summary {
flex-direction: column;
gap: 0.25rem;
}
.comparador-modal {
padding: 1.5rem;
}
.comparador-selectors {
flex-direction: column;
gap: 0.75rem;
}
.vs {
display: none;
}
.kpi-values {
gap: 1rem;
}
.kpi-val {
font-size: 0.75rem;
min-width: 60px;
}
.comp-bar {
width: 12px;
}
.grouped-chart {
gap: 0.5rem;
}
}
/* ========== TEMA CLARO ========== */
.tema-claro {
background: #FAFAF8;
color: #1A1A18;
}
.tema-claro header {
border-bottom-color: #E5E5E0;
}
.tema-claro .breadcrumb a {
color: #8A8578;
}
.tema-claro .breadcrumb a:hover {
color: #1A1A18;
}
.tema-claro .breadcrumb-sep {
color: #C5C5C0;
}
.tema-claro .codigo {
color: #8A8578;
}
.tema-claro h1 {
color: #1A1A18;
}
.tema-claro .help-icon {
color: #8A8578;
}
.tema-claro .help-icon:hover,
.tema-claro .help-icon.active {
color: #C9A227;
}
.tema-claro .badge {
background: #F0F0EC;
border-color: #E5E5E0;
color: #5A5650;
}
.tema-claro .years {
color: #8A8578;
}
.tema-claro .info-panel {
background: #F5F5F3;
border-bottom-color: #E5E5E0;
}
.tema-claro .info-content h4 {
color: #8A8578;
}
.tema-claro .info-content p {
color: #5A5650;
}
.tema-claro .variacion {
border-left-color: #D5D5D0;
}
.tema-claro .variacion-rango {
color: #8A8578;
}
.tema-claro .hijo-link:hover {
background: rgba(0, 0, 0, 0.03);
}
.tema-claro .hijo-codigo {
color: #8A8578;
}
.tema-claro .hijo-nombre {
color: #5A5650;
}
.tema-claro .hijo-link:hover .hijo-nombre {
color: #1A1A18;
}
.tema-claro .vista-toggle {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .toggle-btn {
color: #8A8578;
}
.tema-claro .toggle-btn:hover {
color: #5A5650;
}
.tema-claro .toggle-btn.active {
background: #1A1A18;
color: #FAFAF8;
}
.tema-claro .entity-selector .selector-label {
color: #8A8578;
}
.tema-claro .interaction-hint {
color: #8A8578;
}
.tema-claro .selector-btn {
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
color: #1A1A18;
}
.tema-claro .selector-btn:hover {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
}
.tema-claro .selector-btn svg {
color: #8A8578;
}
.tema-claro .dropdown-panel {
background: #FAFAF8;
border-color: #E5E5E0;
}
.tema-claro .dropdown-search {
border-bottom-color: #E5E5E0;
}
.tema-claro .search-input {
background: #F5F5F3;
border-color: #E5E5E0;
color: #1A1A18;
}
.tema-claro .search-input:focus {
border-color: #C9A227;
}
.tema-claro .search-input::placeholder {
color: #8A8578;
}
.tema-claro .dropdown-item {
color: #1A1A18;
}
.tema-claro .dropdown-item:hover {
background: rgba(0,0,0,0.03);
}
.tema-claro .dropdown-item.active {
background: rgba(201,162,39,0.12);
}
.tema-claro .entity-code {
color: #8A8578;
}
.tema-claro .chart-title {
color: #8A8578;
}
.tema-claro .chart-period {
color: #333333;
}
.tema-claro .y-label {
color: #8A8578;
}
.tema-claro .y-axis-sticky {
background: #FAFAF8;
}
.tema-claro .chart-wrapper-scroll::-webkit-scrollbar-thumb {
background: #D5D5D0;
}
.tema-claro .chart {
border-left-color: #E5E5E0;
}
.tema-claro .bar-container .bar {
background: #2a9d8f;
}
.tema-claro .bar-container .bar.hovered {
background: #C9A751;
}
/* Las barras de comparación mantienen su --bar-color */
.tema-claro .bar-a,
.tema-claro .bar-b {
background: var(--bar-color);
}
.tema-claro .bar-label {
color: #333333;
}
.tema-claro .kpis {
background: transparent;
}
.tema-claro .kpi {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .kpi-value {
color: #1A1A18;
}
.tema-claro .kpi-label {
color: #8A8578;
}
.tema-claro .top-section h3 {
color: #8A8578;
}
.tema-claro .top-context {
color: #8A8578;
}
.tema-claro .top-item {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .top-rank {
color: #8A8578;
}
.tema-claro .top-name {
color: #1A1A18;
}
.tema-claro .top-bar-bg {
background: #F0F0EC;
}
.tema-claro .top-bar {
background: #C9A751;
}
.tema-claro .top-item:hover {
box-shadow: inset 0 0 0 1px rgba(201, 167, 81, 0.5);
}
.tema-claro .top-item:hover .top-bar {
background: #B8963F;
}
.tema-claro .top-pct {
color: #333333;
}
/* Comparador - tema claro */
.tema-claro .comparador-selectors .selector-group select {
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
color: #1A1A18;
}
.tema-claro .comparador-selectors .selector-group select:focus {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
outline: none;
}
.tema-claro .comparador-placeholder {
background: rgba(0,0,0,0.02);
border-color: #E5E5E0;
}
.tema-claro .comparador-placeholder p {
color: #8A8578;
}
.tema-claro .comparador-placeholder strong {
color: #5A5650;
}
.tema-claro .rango-label {
color: #8A8578;
}
.tema-claro .rango-btn {
color: #8A8578;
}
.tema-claro .rango-btn:hover {
color: #5A5650;
background: rgba(0,0,0,0.05);
}
.tema-claro .rango-btn.active {
color: #1A1A18;
background: rgba(0,0,0,0.08);
}
.tema-claro .vs {
color: #8A8578;
}
.tema-claro .chart-legend .legend-item {
color: #5A5650;
}
.tema-claro .kpis-tabla {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .tabla-header {
border-bottom-color: #E5E5E0;
}
.tema-claro .tabla-label {
color: #5A5650;
}
.tema-claro .tabla-val {
color: #1A1A18;
}
.tema-claro .bar-group.active {
background: rgba(42, 157, 143, 0.12);
}
.tema-claro .bar-label.highlight {
color: #1A1A18;
}
</style>
......
......@@ -3,6 +3,7 @@
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import Spinner from '$lib/components/ui/Spinner.svelte';
// ══════════════════════════════════════════════════════════════
// ESTADO GENERAL
......@@ -886,7 +887,7 @@
</div>
{:else if loadingComparacion}
<div class="comparador-placeholder">
<p>Cargando datos...</p>
<Spinner size={48} color="var(--theme-texto, #5A5650)" />
</div>
{:else if añosComparacion.length === 0}
<div class="comparador-placeholder">
......