Rafael Lopez

general

......@@ -9,7 +9,6 @@
"version": "0.0.1",
"dependencies": {
"@observablehq/plot": "^0.6.17",
"@supabase/supabase-js": "^2.98.0",
"@sveltejs/adapter-node": "^5.5.4",
"d3": "^7.9.0",
"fuse.js": "^7.1.0",
......@@ -939,86 +938,6 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.98.0.tgz",
"integrity": "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.98.0.tgz",
"integrity": "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.98.0.tgz",
"integrity": "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.98.0.tgz",
"integrity": "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.98.0.tgz",
"integrity": "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.98.0.tgz",
"integrity": "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.98.0",
"@supabase/functions-js": "2.98.0",
"@supabase/postgrest-js": "2.98.0",
"@supabase/realtime-js": "2.98.0",
"@supabase/storage-js": "2.98.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
......@@ -1415,21 +1334,6 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz",
"integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
......@@ -1442,15 +1346,6 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
......@@ -2114,15 +2009,6 @@
"integrity": "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==",
"license": "MIT"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
......@@ -2775,13 +2661,9 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/vite": {
"version": "7.3.1",
......@@ -2876,27 +2758,6 @@
}
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
......
......@@ -20,7 +20,6 @@
},
"dependencies": {
"@observablehq/plot": "^0.6.17",
"@supabase/supabase-js": "^2.98.0",
"@sveltejs/adapter-node": "^5.5.4",
"d3": "^7.9.0",
"fuse.js": "^7.1.0",
......
import Fuse from 'fuse.js';
import { supabase } from '$lib/supabase';
import { normalizeText } from '$lib/utils/normalize';
let fuseInstance = null;
let searchData = [];
/**
* Configuración de Fuse.js
*/
const fuseOptions = {
keys: [
{ name: 'nombre_normalizado', weight: 0.6 },
{ name: 'sigla_normalizada', weight: 0.2 },
{ name: 'codigo_normalizado', weight: 0.2 }
],
threshold: 0.4, // 0 = exacto, 1 = muy fuzzy
distance: 100,
includeScore: true,
includeMatches: true,
minMatchCharLength: 2,
ignoreLocation: true
};
/**
* Carga las entidades y objetos de gasto desde Supabase y construye el índice
*/
export async function initSearchIndex() {
if (fuseInstance) return fuseInstance;
// Cargar entidades
const { data: entidades, error: errorEntidades } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('entidad, desc_entidad, sigla_entidad, desc_area, n_gestiones');
if (errorEntidades) {
console.error('Error cargando entidades:', errorEntidades);
throw errorEntidades;
}
// Cargar objetos de gasto
const { data: objetos, error: errorObjetos } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('objeto, desc_objeto, nivel');
if (errorObjetos) {
console.error('Error cargando objetos:', errorObjetos);
throw errorObjetos;
}
// Cargar rubros
const { data: rubros, error: errorRubros } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('rubro, desc_rubro, nivel');
if (errorRubros) {
console.error('Error cargando rubros:', errorRubros);
throw errorRubros;
}
// Cargar finalidad y función
const { data: finfun, error: errorFinfun } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('finfun, desc_finfun, nivel');
if (errorFinfun) {
console.error('Error cargando finalidad/función:', errorFinfun);
throw errorFinfun;
}
// Cargar organismos (no bloquea si falla)
let organismos = [];
const { data: organismosData, error: errorOrganismos } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('organismo, organismo_desc_organismo, organismo_grupo, organismo_sub_grupo');
if (errorOrganismos) {
console.warn('Tabla clas_organismos no disponible:', errorOrganismos.message);
} else {
organismos = organismosData || [];
}
// Cargar fuentes (no bloquea si falla)
let fuentes = [];
const { data: fuentesData, error: errorFuentes } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('fuente, desc_fuente, grupo_fuente, desc_grupo_fuente');
if (errorFuentes) {
console.warn('Tabla clas_fuentes no disponible:', errorFuentes.message);
} else {
fuentes = fuentesData || [];
}
// Transformar entidades para el índice
const entidadesIndex = entidades.map(item => ({
tipo: 'entidad',
codigo: item.entidad,
nombre: item.desc_entidad,
sigla: item.sigla_entidad,
contexto: item.desc_area,
años: item.n_gestiones,
nombre_normalizado: normalizeText(item.desc_entidad),
sigla_normalizada: normalizeText(item.sigla_entidad),
codigo_normalizado: item.entidad?.toString() || ''
}));
// Transformar objetos de gasto para el índice
const nivelLabelsObjetos = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
const objetosIndex = objetos.map(item => ({
tipo: 'objeto_gasto',
codigo: item.objeto,
nombre: item.desc_objeto,
sigla: null,
contexto: `Objeto del Gasto · ${nivelLabelsObjetos[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_objeto),
sigla_normalizada: '',
codigo_normalizado: item.objeto || ''
}));
// Transformar rubros para el índice
const nivelLabelsRubros = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
const rubrosIndex = rubros.map(item => ({
tipo: 'rubro',
codigo: item.rubro,
nombre: item.desc_rubro,
sigla: null,
contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_rubro),
sigla_normalizada: '',
codigo_normalizado: item.rubro?.toString() || ''
}));
// Transformar finalidad y función para el índice
const nivelLabelsFinfun = { finalidad: 'Finalidad', grpfuncion: 'Grupo Función', funcion: 'Función' };
const finfunIndex = finfun.map(item => ({
tipo: 'finfun',
codigo: item.finfun,
nombre: item.desc_finfun,
sigla: null,
contexto: `Finalidad y Función · ${nivelLabelsFinfun[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_finfun),
sigla_normalizada: '',
codigo_normalizado: item.finfun?.toString() || ''
}));
// Transformar organismos para el índice
// Todos los registros en la tabla son organismos (nivel hoja)
const organismosIndex = organismos.map(item => ({
tipo: 'organismo',
codigo: item.organismo,
nombre: item.organismo_desc_organismo,
sigla: null,
contexto: `Organismo · Grupo ${item.organismo_grupo}`,
nivel: 'organismo',
nombre_normalizado: normalizeText(item.organismo_desc_organismo),
sigla_normalizada: '',
codigo_normalizado: item.organismo?.toString() || ''
}));
// Transformar fuentes para el índice
const fuentesIndex = fuentes.map(item => ({
tipo: 'fuente',
codigo: item.fuente,
nombre: item.desc_fuente,
sigla: null,
contexto: `Fuente · ${item.desc_grupo_fuente}`,
nivel: 'fuente',
nombre_normalizado: normalizeText(item.desc_fuente),
sigla_normalizada: '',
codigo_normalizado: item.fuente?.toString() || ''
}));
// Combinar todos los conjuntos
searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex, ...finfunIndex, ...organismosIndex, ...fuentesIndex];
fuseInstance = new Fuse(searchData, fuseOptions);
return fuseInstance;
}
/**
* Busca en el índice
* @param {string} query - Texto de búsqueda
* @param {number} limit - Máximo de resultados (default 30)
* @returns {Array} Resultados de búsqueda
*/
export function search(query, limit = 30) {
if (!fuseInstance) {
console.warn('Índice no inicializado. Llama initSearchIndex() primero.');
return [];
}
if (!query || query.length < 2) {
return [];
}
const normalizedQuery = normalizeText(query);
const results = fuseInstance.search(normalizedQuery, { limit });
return results.map(result => ({
...result.item,
score: result.score,
matches: result.matches
}));
}
/**
* Obtiene todas las entidades (sin búsqueda)
*/
export function getAllEntidades() {
return searchData.filter(item => item.tipo === 'entidad');
}
/**
* Obtiene todos los objetos de gasto (sin búsqueda)
*/
export function getAllObjetos() {
return searchData.filter(item => item.tipo === 'objeto_gasto');
}
/**
* Verifica si el índice está cargado
*/
export function isIndexReady() {
return fuseInstance !== null;
}
import { writable, derived, get } from 'svelte/store';
import { initSearchIndex, search, isIndexReady } from '$lib/services/search';
import { writable, derived } from 'svelte/store';
// Estado del buscador
export const query = writable('');
export const results = writable([]);
export const isLoading = writable(false);
export const indexLoaded = writable(false);
export const indexLoaded = writable(true);
export const error = writable(null);
export const selectedIndex = writable(-1);
export const filterType = writable('todos'); // todos, entidad, objeto_gasto, rubro, finfun
export const filterType = writable('todos');
// Estado derivado: si está mostrando resultados
export const showResults = derived(
[query, results, indexLoaded],
([$query, $results, $indexLoaded]) => $indexLoaded && $query.length >= 2
);
/**
* Inicializa el índice de búsqueda
*/
export async function initIndex() {
if (isIndexReady()) {
indexLoaded.set(true);
return;
}
isLoading.set(true);
error.set(null);
try {
await initSearchIndex();
indexLoaded.set(true);
} catch (e) {
error.set('Error al cargar el índice de búsqueda');
console.error(e);
} finally {
isLoading.set(false);
}
indexLoaded.set(true);
}
/**
* Realiza la búsqueda
*/
export function performSearch(searchQuery, filter = null) {
query.set(searchQuery);
selectedIndex.set(-1);
if (searchQuery.length < 2) {
results.set([]);
return;
}
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));
}
/**
* Limpia la búsqueda
*/
export function performSearch() {}
export function clearSearch() {
query.set('');
results.set([]);
selectedIndex.set(-1);
}
/**
* 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) {
selectedIndex.update(current => {
if (direction === 'down') {
return current < totalResults - 1 ? current + 1 : 0;
} else if (direction === 'up') {
return current > 0 ? current - 1 : totalResults - 1;
}
return current;
});
}
export function setFilter() {}
export function navigateResults() {}
......
import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
import { supabase } from '$lib/supabase';
import { json } from '@sveltejs/kit';
export async function GET() {
const { data, error } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('*')
.limit(5);
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json({
success: true,
count: data.length,
columns: data.length > 0 ? Object.keys(data[0]) : [],
sample: data
});
}
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
let loading = $state(true);
let searchQuery = $state('');
let grupos = $state([]);
let allItems = $state([]);
let selectedGrupo = $state(null);
let selectedItem = $state(null);
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
onMount(async () => {
// Forzar recálculo de layout para navegación cliente
requestAnimationFrame(() => {
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('*')
.order('fuente');
if (!error && data) {
allItems = data;
// Crear grupos sintéticos desde valores únicos de grupo_fuente
const gruposUnicos = [...new Set(data.map(item => item.grupo_fuente))].filter(g => g != null);
grupos = gruposUnicos.map(grupoNum => {
const sample = data.find(item => item.grupo_fuente === grupoNum);
return {
grupo_fuente: grupoNum,
desc_grupo_fuente: sample?.desc_grupo_fuente || `Grupo ${grupoNum}`
};
}).sort((a, b) => Number(a.grupo_fuente) - Number(b.grupo_fuente));
if (grupos.length > 0) {
selectedGrupo = grupos[0];
}
}
loading = false;
});
// Obtener fuentes para un grupo específico
function getFuentesForGrupo(grupo) {
if (!grupo) return [];
return allItems
.filter(item => item.grupo_fuente === grupo.grupo_fuente)
.sort((a, b) => String(a.fuente).localeCompare(String(b.fuente)));
}
// Búsqueda global
function searchGlobal(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allItems
.filter(item =>
item.fuente?.toString().includes(q) ||
item.desc_fuente?.toLowerCase().includes(q) ||
item.desc_grupo_fuente?.toLowerCase().includes(q)
)
.slice(0, 20);
}
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
function getMaxYear(rangos) {
if (!rangos) return 0;
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
}
function selectGrupo(grupo) {
selectedGrupo = grupo;
selectedItem = null;
searchQuery = '';
highlightedItem = null;
sidebarOpen = false;
}
function openDetail(item) {
selectedItem = item;
}
function closeDetail() {
selectedItem = null;
}
function goToSearchResult(item) {
const targetGrupo = grupos.find(g => g.grupo_fuente === item.grupo_fuente);
if (targetGrupo) {
selectedGrupo = targetGrupo;
highlightedItem = item.fuente;
searchQuery = '';
sidebarOpen = false;
setTimeout(() => {
const element = document.getElementById(`f-${item.fuente}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
setTimeout(() => {
highlightedItem = null;
}, 2000);
}
}
let grupoContent = $derived(selectedGrupo ? getFuentesForGrupo(selectedGrupo) : []);
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
// Contadores
let totalItems = $derived(allItems.length);
let totalGrupos = $derived(grupos.length);
</script>
<svelte:head>
<title>Fuentes de Financiamiento | Presupuesto Público</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="min-h-screen" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo); transition: background-color 0.2s, color 0.2s;">
<!-- Header pedagógico -->
<header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
<!-- Breadcrumb: responsive -->
<nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<!-- Móvil: solo padre -->
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
</a>
<!-- Desktop: ruta completa -->
<div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
<a href="/" class="transition-colors hover:opacity-80">Inicio</a>
<span style="opacity: 0.5;">/</span>
<a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
</div>
</nav>
<div class="max-w-3xl">
<p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Clasificador 02
</p>
<h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
¿Con qué recursos se financia?
</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Organiza los recursos según su <strong style="color: var(--theme-titulo);">origen de financiamiento</strong>:
Tesoro General, recursos específicos, créditos externos e internos, donaciones.
Permite entender de dónde provienen los fondos para el gasto público.
</p>
{#if totalItems > 0}
<p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> fuentes de financiamiento en
<span style="color: var(--theme-titulo); font-weight: 500;">{totalGrupos}</span> grupos
</p>
{/if}
<!-- Jerarquía -->
<div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
<span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
Grupo
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
Fuente
</span>
</div>
<!-- Versión móvil simplificada -->
<p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Jerarquía: Grupo → Fuente
</p>
</div>
</div>
</header>
{#if loading}
<div class="flex items-center justify-center py-20">
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
</div>
{:else}
<!-- Botón móvil para abrir sidebar -->
<div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
<button
onclick={() => sidebarOpen = !sidebarOpen}
class="flex items-center gap-2 text-sm"
style="color: var(--theme-texto);"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="font-medium">{selectedGrupo ? selectedGrupo.desc_grupo_fuente : 'Seleccionar grupo'}</span>
<svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
<!-- Sidebar izquierda: Grupos -->
{#if sidebarOpen}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/30 z-40 lg:hidden"
onclick={() => sidebarOpen = false}
></div>
{/if}
<aside class="
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
lg:translate-x-0
fixed lg:relative
inset-y-0 left-0
w-72 lg:w-64
z-50 lg:z-auto
transition-transform duration-200 ease-in-out
lg:flex-shrink-0
shadow-xl lg:shadow-none
sidebar-left
">
<div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-6">
<!-- Cerrar en móvil -->
<div class="flex justify-between items-center mb-4 lg:hidden">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de fuentes</span>
<button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Buscador -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar..."
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
/>
</div>
<!-- Resultados de búsqueda -->
{#if isSearching}
<div class="mb-4">
<p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
{searchResults.length} resultados
</p>
<div class="space-y-1">
{#each searchResults as result}
<button
class="w-full text-left px-2 py-2 text-sm rounded transition-all"
style="color: var(--theme-titulo);"
onclick={() => goToSearchResult(result)}
>
<span class="text-xs block" style="color: var(--theme-texto);">Fuente</span>
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.fuente}</span>
<span class="ml-1">{result.desc_fuente}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
{/if}
</div>
</div>
{:else}
<!-- Lista de grupos -->
<nav>
<p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
<ul class="space-y-1">
{#each grupos as grupo}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
style="{selectedGrupo?.grupo_fuente === grupo.grupo_fuente
? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 2px solid var(--theme-accent);`
: `color: var(--theme-texto);`}"
onclick={() => selectGrupo(grupo)}
>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">Grupo {grupo.grupo_fuente}</span>
{grupo.desc_grupo_fuente}
</button>
</li>
{/each}
</ul>
</nav>
{/if}
</div>
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
<div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{#if selectedGrupo}
{#key selectedGrupo.grupo_fuente}
<div in:fade={{ duration: 200, delay: 50 }}>
<!-- Título del grupo -->
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">Grupo {selectedGrupo.grupo_fuente}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
{selectedGrupo.desc_grupo_fuente}
</h2>
<p class="text-sm" style="color: var(--theme-texto);">
{grupoContent.length} fuentes de financiamiento en este grupo
</p>
</div>
<!-- Fuentes -->
<div class="space-y-8">
{#each grupoContent as fuente}
{@const fuenteDescs = parseDescripciones(fuente.descripciones)}
<section
id="f-{fuente.fuente}"
class="scroll-mt-4 {highlightedItem === fuente.fuente ? 'highlighted' : ''}"
>
<div class="flex items-start gap-2 sm:gap-4 mb-3">
<span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{fuente.fuente}</span>
<div class="flex-1">
<h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{fuente.desc_fuente}</span>
{#if fuente.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(fuente)}
title="Ver variaciones de descripción"
>
{fuente.n_variaciones} var.
</button>
{/if}
<a
href="/fuente/{fuente.fuente}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h3>
{#if fuenteDescs.length > 0}
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{fuenteDescs[0].descripcion}</p>
{/if}
<!-- Vigencia temporal -->
{#if fuente.gestiones}
<p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
Vigente: {fuente.gestiones}
</p>
{/if}
</div>
</div>
</section>
{/each}
</div>
</div>
{/key}
{/if}
</div>
</main>
<!-- Sidebar derecha: En esta página -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
{#if selectedGrupo && !isSearching}
<nav class="space-y-2">
{#each grupoContent as fuente}
<a
href="#f-{fuente.fuente}"
class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="{fuente.desc_fuente}"
>
<span class="font-mono text-xs" style="opacity: 0.6;">{fuente.fuente}</span>
{fuente.desc_fuente}
</a>
{/each}
</nav>
{/if}
</div>
</aside>
</div>
{/if}
</div>
<!-- Modal de detalle -->
{#if selectedItem}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
onclick={closeDetail}
>
<div
transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
style="background-color: var(--theme-surface);"
onclick={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<div>
<p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">Fuente de Financiamiento</p>
<h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
<span class="font-mono" style="color: var(--theme-texto);">{selectedItem.fuente}</span>
<span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
{selectedItem.desc_fuente}
</h3>
</div>
<button
onclick={closeDetail}
class="text-2xl leading-none"
style="color: var(--theme-texto);"
>
&times;
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
{#if selectedItem.n_variaciones > 1}
Descripciones ({selectedItem.n_variaciones} variaciones)
{:else}
Descripción
{/if}
</h4>
<div class="space-y-4">
{#each parseDescripciones(selectedItem.descripciones) as desc, i}
<div class="border-l-2 pl-4 py-3 rounded-r" style="{i === 0 ? `border-color: var(--theme-accent); background-color: color-mix(in srgb, var(--theme-accent) 10%, transparent);` : `border-color: var(--theme-borde);`}">
<p class="text-sm mb-2" style="color: var(--theme-texto);">
{#if i === 0 && selectedItem.n_variaciones > 1}
<span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
<span class="mx-1" style="opacity: 0.5;">·</span>
{/if}
<span class="font-mono">{desc.rangos}</span>
</p>
<p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
</div>
{/each}
</div>
<!-- Vigencia temporal -->
{#if selectedItem.gestiones}
<div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
<p class="text-xs font-mono" style="color: var(--theme-texto);">
<span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
/* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
.sidebar-left {
background-color: var(--theme-surface);
}
@media (min-width: 1024px) {
.sidebar-left {
background-color: transparent;
}
}
/* Highlight pulse animation para resultados de búsqueda */
@keyframes highlight-pulse {
0%, 100% {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent);
}
50% {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--theme-accent) 50%, transparent);
}
}
.highlighted {
animation: highlight-pulse 0.8s ease-in-out 2;
border-radius: 0.5rem;
padding: 0.5rem;
margin-left: -0.5rem;
}
/* Layout principal - fallback nativo */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
/* Sidebar - fallback para responsive */
.sidebar-left {
position: fixed;
transform: translateX(-100%);
}
@media (min-width: 1024px) {
.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0;
width: 16rem;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
}
</style>
......@@ -69,6 +69,7 @@
}
} catch { entityError = true; }
loadingEntities = false;
console.log('[DESCARGAS] entities loaded:', entities.length);
});
</script>
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
console.time('[SERVER] Total load fuente');
const { codigo } = params;
console.time('[SERVER] Query clas_fuentes');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('*')
.eq('fuente', codigo);
console.timeEnd('[SERVER] Query clas_fuentes');
if (dbError || !data || data.length === 0) {
throw error(404, 'Fuente no encontrada');
}
const fuente = data[0];
// Obtener hermanos (otras fuentes del mismo grupo)
let hermanos = [];
console.time('[SERVER] Query hermanos');
const { data: hermanosData } = await supabase
.schema('ppto')
.from('clas_fuentes')
.select('*')
.eq('grupo_fuente', fuente.grupo_fuente)
.neq('fuente', codigo)
.order('fuente');
if (hermanosData) {
hermanos = hermanosData;
}
console.timeEnd('[SERVER] Query hermanos');
console.timeEnd('[SERVER] Total load fuente');
return {
fuente,
hermanos
};
}
<script>
import { onMount } from 'svelte';
// Datos del servidor (reactivos)
let { data } = $props();
let fuenteData = $derived(data.fuente);
let hermanos = $derived(data.hermanos);
// Estado
let mounted = $state(false);
onMount(() => {
setTimeout(() => { mounted = true; }, 50);
});
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
function getMaxYear(rangos) {
if (!rangos) return 0;
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
}
let descripciones = $derived(parseDescripciones(fuenteData.descripciones));
</script>
<svelte:head>
<title>{fuenteData.desc_fuente || fuenteData.fuente} | Clasificador de Fuentes</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<!-- Link a clasificadores -->
<nav class="page-nav">
<a href="/clasificadores/fuentes" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
<div class="dashboard-layout">
<!-- CONTEXTO IZQUIERDO -->
<aside class="dashboard-context">
<div class="context-card">
<h2 class="context-main-title">Contexto</h2>
<!-- Info del grupo -->
<div class="context-section">
<h3 class="context-subtitle">Ubicación</h3>
<div class="ubicacion-info">
<div class="ubicacion-item">
<span class="ubicacion-label">Grupo {fuenteData.grupo_fuente}</span>
<span class="ubicacion-value">{fuenteData.desc_grupo_fuente}</span>
</div>
</div>
</div>
<!-- Hermanos -->
{#if hermanos.length > 0}
<div class="context-separator"></div>
<div class="context-section">
<h3 class="context-subtitle">Mismo grupo ({hermanos.length})</h3>
<p class="context-hint">Otras fuentes del mismo grupo.</p>
<div class="hermanos-list">
{#each hermanos.slice(0, 10) as hermano}
<a href="/fuente/{hermano.fuente}" class="hermano-link">
<span class="hermano-code">{hermano.fuente}</span>
<span class="hermano-name">{hermano.desc_fuente}</span>
</a>
{/each}
{#if hermanos.length > 10}
<span class="hermanos-more">+{hermanos.length - 10} más</span>
{/if}
</div>
</div>
{/if}
</div>
</aside>
<!-- CONTENIDO PRINCIPAL -->
<main class="dashboard-main">
<div class="main-content">
<!-- Header -->
<div class="main-header">
<div class="main-title-pills">
<span class="pill pill-codigo">{fuenteData.fuente}</span>
</div>
<h1 class="main-title-name">{fuenteData.desc_fuente}</h1>
</div>
<!-- Info cards -->
<div class="info-grid">
<div class="info-card">
<span class="info-label">Grupo</span>
<span class="info-value">{fuenteData.grupo_fuente}</span>
<span class="info-desc">{fuenteData.desc_grupo_fuente}</span>
</div>
{#if fuenteData.gestiones}
<div class="info-card">
<span class="info-label">Gestiones</span>
<span class="info-value">{fuenteData.n_gestiones || ''} años</span>
<span class="info-desc">{fuenteData.gestiones}</span>
</div>
{/if}
{#if fuenteData.n_variaciones > 1}
<div class="info-card">
<span class="info-label">Variaciones</span>
<span class="info-value">{fuenteData.n_variaciones}</span>
<span class="info-desc">Cambios en la descripción a lo largo del tiempo</span>
</div>
{/if}
</div>
<!-- Descripciones -->
{#if descripciones.length > 0}
<div class="descripciones-section">
<h2 class="descripciones-title">
{#if descripciones.length > 1}
Descripciones ({descripciones.length} variaciones)
{:else}
Descripción
{/if}
</h2>
<div class="descripciones-list">
{#each descripciones as desc, i}
<div class="descripcion-item" class:vigente={i === 0}>
<p class="descripcion-rangos">
{#if i === 0 && descripciones.length > 1}
<span class="vigente-badge">Vigente</span>
{/if}
{desc.rangos}
</p>
<p class="descripcion-texto">{desc.descripcion}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</main>
</div>
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
BASE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
padding: 1rem;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.4s ease, transform 0.4s ease;
}
.page.mounted {
opacity: 1;
transform: translateY(0);
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
max-width: 1400px;
margin: 0 auto 1.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary, #888);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
}
.back-link:hover {
color: var(--text-primary, #fff);
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-context {
order: 2;
}
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
position: sticky;
top: 5rem;
height: fit-content;
}
.context-card {
background: var(--card-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 1.5rem;
}
.context-main-title {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #666);
margin-bottom: 1.5rem;
}
.context-section {
margin-bottom: 1.5rem;
}
.context-subtitle {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #aaa);
margin-bottom: 0.75rem;
}
.context-hint {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
margin-bottom: 1rem;
}
.context-separator {
height: 1px;
background: var(--border-color, rgba(255, 255, 255, 0.08));
margin: 1.5rem 0;
}
/* ═══════════════════════════════════════════════════════════════
UBICACION INFO
═══════════════════════════════════════════════════════════════ */
.ubicacion-info {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ubicacion-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
border-radius: 8px;
}
.ubicacion-label {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--text-tertiary, #666);
text-transform: uppercase;
}
.ubicacion-value {
font-size: 0.85rem;
color: var(--text-primary, #fff);
}
/* ═══════════════════════════════════════════════════════════════
HERMANOS LIST
═══════════════════════════════════════════════════════════════ */
.hermanos-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hermano-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
text-decoration: none;
color: var(--text-secondary, #aaa);
font-size: 0.8rem;
transition: all 0.2s ease;
}
.hermano-link:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-primary, #fff);
}
.hermano-code {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
opacity: 0.6;
flex-shrink: 0;
min-width: 2.5rem;
}
.hermano-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hermanos-more {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
padding: 0.5rem 0.75rem;
}
/* ═══════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
min-width: 0;
}
.main-content {
background: var(--card-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 2rem;
}
.main-header {
margin-bottom: 2rem;
}
.main-title-pills {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.pill {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.pill-codigo {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--text-secondary, #aaa);
font-family: 'DM Mono', monospace;
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
color: var(--text-primary, #fff);
line-height: 1.2;
margin: 0;
}
/* ═══════════════════════════════════════════════════════════════
INFO GRID
═══════════════════════════════════════════════════════════════ */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.info-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--hover-bg, rgba(255, 255, 255, 0.03));
border-radius: 12px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
}
.info-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary, #666);
}
.info-value {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
color: var(--text-primary, #fff);
}
.info-desc {
font-size: 0.8rem;
color: var(--text-secondary, #aaa);
line-height: 1.4;
}
/* ═══════════════════════════════════════════════════════════════
DESCRIPCIONES
═══════════════════════════════════════════════════════════════ */
.descripciones-section {
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
padding-top: 2rem;
}
.descripciones-title {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #aaa);
margin-bottom: 1rem;
}
.descripciones-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.descripcion-item {
padding: 1rem;
border-left: 2px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 0 8px 8px 0;
background: var(--hover-bg, rgba(255, 255, 255, 0.02));
}
.descripcion-item.vigente {
border-left-color: var(--theme-accent, #3b82f6);
background: color-mix(in srgb, var(--theme-accent, #3b82f6) 10%, transparent);
}
.descripcion-rangos {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--text-tertiary, #666);
margin-bottom: 0.5rem;
}
.vigente-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--theme-accent, #3b82f6);
color: white;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 0.5rem;
}
.descripcion-texto {
font-size: 0.9rem;
color: var(--text-primary, #fff);
line-height: 1.6;
}
</style>
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { supabase } from '$lib/supabase';
import {
query,
results,
isLoading,
indexLoaded,
error,
selectedIndex,
showResults,
initIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
// Pre-warm Supabase connection (evita cold start al navegar)
async function warmupSupabase() {
console.time('[WARMUP] Supabase');
await supabase.schema('ppto').from('vista_objeto_estado').select('objeto').limit(1);
console.timeEnd('[WARMUP] Supabase');
}
let mounted = false;
let mode = 'archivo';
let searchFocused = false;
let searchVal = '';
let hDiario = false;
let hArchivo = false;
let hVis = null;
let hHist = null;
let hWidget = null;
let hGear = false;
let mobileSettingsOpen = false;
let gearMenuOpen = false;
let searchInput;
let debounceTimer;
let autoFocused = false;
// Search filters - which types to include
let searchFilters = {
entidad: true,
objeto_gasto: true,
rubro: true
};
// Filtered results based on selected filters
$: filteredResults = $results.filter(item => searchFilters[item.tipo]);
function toggleFilter(tipo) {
searchFilters[tipo] = !searchFilters[tipo];
searchFilters = searchFilters; // trigger reactivity
}
function toggleGearMenu() {
gearMenuOpen = !gearMenuOpen;
}
onMount(() => {
setTimeout(() => { mounted = true; }, 80);
initIndex();
// Pre-calentar Supabase en background para evitar cold start
warmupSupabase();
// Cerrar gear menu al hacer click fuera
const handleClickOutside = (e) => {
if (gearMenuOpen && !e.target.closest('.gear-wrap')) {
gearMenuOpen = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
// Auto-focus en el buscador cuando el índice esté listo (solo desktop)
$: if ($indexLoaded && searchInput && !autoFocused) {
autoFocused = true;
// No auto-focus en móvil para evitar que aparezca el teclado
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
if (!isMobile) {
setTimeout(() => {
searchInput?.focus();
}, 50);
}
}
function toggleMobileSettings() {
mobileSettingsOpen = !mobileSettingsOpen;
}
$: isD = mode === 'diario';
$: accent = isD ? '#4A8B6E' : '#C9A751';
$: placeholder = isD
? 'Busca una entidad, un ministerio, un municipio...'
: 'Busca instituciones, gastos, ingresos, sectores...';
$: examples = isD
? [
{ label: 'MEFP', bg: 'rgba(140,170,150,0.10)', color: '#9ab8a8' },
{ label: 'Min. Educación', bg: 'rgba(150,155,175,0.10)', color: '#a0a5b8' },
{ label: 'GOB. La Paz', bg: 'rgba(180,130,100,0.10)', color: '#b8947a' },
]
: [
{ label: 'Sueldos', bg: 'rgba(201,167,81,0.12)', color: '#d4b962', href: '/objeto/11700' },
{ label: 'Regalías', bg: 'rgba(160,150,170,0.09)', color: '#aaa0b0', href: '/rubro/14000' },
{ label: 'BOA', bg: 'rgba(150,155,175,0.10)', color: '#a0a5b8' },
{ label: 'MEFP', bg: 'rgba(140,170,150,0.10)', color: '#9ab8a8' },
{ label: 'Publicidad', bg: 'rgba(180,130,100,0.10)', color: '#b8947a' },
];
function switchMode(m) {
mode = m;
searchVal = '';
clearSearch();
if (searchInput) searchInput.value = '';
}
function handleBlur() {
setTimeout(() => { searchFocused = false; }, 200);
}
function handleInput(e) {
const value = e.target.value;
searchVal = value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
performSearch(value);
}, 250);
}
function handleKeydown(e) {
if (!$showResults) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
navigateResults('down', filteredResults.length);
break;
case 'ArrowUp':
e.preventDefault();
navigateResults('up', filteredResults.length);
break;
case 'Enter':
e.preventDefault();
// Si hay un resultado seleccionado, navegar a él
if ($selectedIndex >= 0 && filteredResults[$selectedIndex]) {
handleSelect(filteredResults[$selectedIndex]);
} else if (filteredResults.length > 0) {
// Si no hay selección pero hay resultados, ir al primero
handleSelect(filteredResults[0]);
}
break;
case 'Escape':
clearSearch();
searchVal = '';
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();
searchVal = '';
if (searchInput) searchInput.value = '';
}
function getTypeLabel(tipo) {
switch (tipo) {
case 'entidad': return 'ENTIDAD';
case 'objeto_gasto': return 'OBJETO';
case 'rubro': return 'RUBRO';
default: return tipo?.toUpperCase() || '';
}
}
function getTypeColor(tipo) {
switch (tipo) {
case 'entidad': return '#4A8B6E';
case 'objeto_gasto': return '#C9A751';
case 'rubro': return '#8B6E9B';
default: return '#6B6860';
}
}
const WIDGETS_INGRESOS = [
{ id: 'rubros', label: 'tipos de ingreso', d: 'Impuestos, regalías, ventas, créditos', t: 'Recursos por rubros', n: '324', href: '/clasificadores/rubros' },
{ id: 'fuentes', label: 'fuentes de financiamiento', d: 'TGN, crédito externo, donaciones', t: 'Fuentes', n: '22', href: '/clasificadores/fuente' },
{ id: 'organismos', label: 'organismos financiadores', d: 'BID, Banco Mundial, cooperación', t: 'Organismos', n: '228', href: '/clasificadores/organismos' },
];
const WIDGETS_GASTOS_QUE = [
{ id: 'objeto', label: 'objetos de gasto', d: 'Sueldos, consultorías, materiales', t: 'Objeto del gasto', n: '324', href: '/clasificadores/objeto-gasto' },
{ id: 'sector', label: 'sectores económicos', d: 'Industria, construcción, servicios', t: 'Sectores', n: '488', href: '/clasificadores/sectores' },
];
const WIDGETS_GASTOS_BOTTOM = [
{ id: 'finalidad', label: 'finalidades y funciones', d: 'Educación, salud, defensa', t: 'Finalidad', n: '132', href: '/clasificadores/funcional' },
{ id: 'donde', label: 'ubicaciones geográficas', d: 'Departamentos, municipios', t: 'Geográfico', n: '352', href: '/clasificadores/geografico' },
];
const HISTORIAS = [
{ color: '#C9A751', title: '¿Cuánto gasta Bolivia en consultorías cada año?', desc: 'Un análisis de 20 años revela patrones que todo ciudadano debería conocer. La evolución del gasto en servicios profesionales muestra tendencias inesperadas.', tag: 'INVESTIGACIÓN', tall: true },
{ color: '#C44B3F', title: 'Salud vs. Defensa: 20 años', desc: 'Cómo se redistribuyó el presupuesto entre sectores.', tag: 'COMPARATIVA', tall: false },
{ color: '#4A8B6E', title: 'El boom del gasto en infraestructura', desc: 'De 2006 a 2014 Bolivia multiplicó por 8 su inversión pública. Un recorrido por las grandes obras y su impacto fiscal.', tag: 'CRÓNICA', tall: true },
{ color: '#6B8CA6', title: '¿Quién financia a Bolivia?', desc: 'Un mapa de los organismos internacionales que prestan al país.', tag: 'ANÁLISIS', tall: false },
];
const VISUALIZACIONES = [
{ color: '#4A8B6E', title: 'Evolución del gasto en salud', desc: 'Cómo cambiaron las prioridades en salud a lo largo de dos décadas.', type: 'Serie temporal', href: null },
{ color: '#C9A751', title: '¿En qué se gasta?', desc: 'Explora el presupuesto por objeto de gasto.', type: 'Treemap', href: '/clasificadores/objeto-gasto?modo=mapa' },
{ color: '#8B6E9B', title: 'Mapa de inversión por departamento', desc: 'Distribución geográfica de la inversión pública.', type: 'Mapa', href: null },
];
/* Reveal action */
let revealStates = {};
function reveal(node, { id, delay = 0 }) {
revealStates[id] = false;
revealStates = revealStates;
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
setTimeout(() => { revealStates[id] = true; revealStates = revealStates; }, delay);
obs.disconnect();
}
}, { threshold: 0.08 });
obs.observe(node);
return { destroy() { obs.disconnect(); } };
}
/* Section navigation */
const SECTIONS_NAV = [
{ id: 'hero', label: 'Inicio' },
{ id: 'datos', label: 'Datos' },
{ id: 'clasificadores', label: 'Clasificadores' },
{ id: 'historias', label: 'Historias' },
{ id: 'visualizaciones', label: 'Visualizaciones' },
{ id: 'manifiesto', label: 'Manifiesto' },
{ id: 'descargas', label: 'Descargas' },
];
let currentSection = 'hero';
onMount(() => {
const sections = document.querySelectorAll('[data-section]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
currentSection = entry.target.dataset.section;
}
});
}, { threshold: 0.5 });
sections.forEach(section => observer.observe(section));
return () => observer.disconnect();
});
function scrollToSection(id) {
const el = document.querySelector(`[data-section="${id}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth' });
}
</script>
<div class="page" class:mounted>
<!-- Section Navigation Dots -->
<nav class="section-dots">
{#each SECTIONS_NAV as s}
<button
class="section-dot"
class:section-dot-active={currentSection === s.id}
on:click={() => scrollToSection(s.id)}
aria-label={s.label}
>
<span class="section-dot-label">{s.label}</span>
</button>
{/each}
</nav>
<!-- ═══ LANDING SCREEN (NAV + HERO) ═══ -->
<section class="landing-screen" data-section="hero">
<nav>
<div class="nav-logo"><img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" /></div>
</nav>
<!-- ═══ HERO ═══ -->
<section class="hero">
<div class="dot-pattern"></div>
<div class="hero-inner">
<!-- Bloque 1: Identidad -->
<div class="hero-identity">
<h1 class="hero-title anim" style="--d:200ms">Presupuesto <span class="gold">Abierto</span></h1>
<p class="hero-sub anim" style="--d:340ms"><a href="/vista-diaria" class="hero-sub-link">Información diaria</a> e histórica de todo el Estado Boliviano.</p>
<div class="hero-stats anim" style="--d:420ms">
<span class="stat-num">21</span> años<span class="stat-dot">·</span><span class="stat-num">8.3M</span> registros<span class="stat-dot">·</span><span class="stat-num">647</span> instituciones<span class="stat-dot">·</span><span class="stat-num">7</span> <a href="#clasificadores" class="stat-link">clasificadores</a><span class="stat-dot">·</span><span class="stat-num">10</span> <a href="#visualizaciones" class="stat-link">visualizaciones</a><span class="stat-dot">·</span><span class="stat-num">4</span> <a href="#historias" class="stat-link">historias</a>
</div>
</div>
<!-- Bloque 2: Búsqueda -->
<div class="hero-search anim" style="--d:520ms">
<div class="search-wrap">
<!-- Search bar unificado -->
<div class="search-bar" class:search-focused={searchFocused} class:search-has-results={searchFocused && $query.length >= 2 && ($results.length > 0 || $results.length === 0 || $isLoading)} style="--accent:{accent}">
<!-- Link al diario + Badge informativo (desktop) -->
<div class="search-meta toggle-desktop">
<a href="/vista-diaria" class="search-meta-item search-diario-link">
Ver gasto de hoy
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
</a>
<span class="search-meta-item search-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
Viendo 2005 – 2025
</span>
</div>
<div class="search-divider"></div>
<!-- Mobile: mode indicator + settings button -->
<button class="mobile-settings-btn" class:mobile-settings-open={mobileSettingsOpen} on:click={toggleMobileSettings}>
<span class="mobile-mode-indicator" style="background:{accent}"></span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={mobileSettingsOpen ? accent : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={searchFocused ? accent : '#B8B5AD'} stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<div class="search-input-wrap">
<input
type="text"
bind:this={searchInput}
bind:value={searchVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => searchFocused = true}
on:blur={handleBlur}
disabled={!$indexLoaded}
/>
{#if !searchVal}
<div class="search-placeholder">
<span class="blink-cursor" class:cursor-diario={isD} class:cursor-archivo={!isD}></span>
<span class="placeholder-text placeholder-desktop">{placeholder}</span>
<span class="placeholder-text placeholder-mobile">{isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...'}</span>
</div>
{/if}
{#if searchVal && !$isLoading}
<button class="search-clear" on:click={() => { clearSearch(); searchVal = ''; if (searchInput) searchInput.value = ''; }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
<!-- Gear button with filter dropdown (desktop) -->
<div class="gear-wrap gear-desktop" on:click|stopPropagation>
<button class="gear-btn" class:gear-active={gearMenuOpen} on:click={toggleGearMenu}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={gearMenuOpen ? accent : '#B8B5AD'} stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
{#if gearMenuOpen}
<div class="gear-menu">
<div class="gear-menu-title">Ajusta tu búsqueda</div>
<button class="gear-row" on:click={() => toggleFilter('entidad')}>
<div class="gear-check" class:gear-check-on={searchFilters.entidad}>
{#if searchFilters.entidad}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</div>
<div class="gear-row-content">
<span class="gear-row-question">¿Quién?</span>
<span class="gear-row-label" class:gear-row-label-on={searchFilters.entidad}>Instituciones</span>
</div>
<span class="gear-row-count">756</span>
</button>
<button class="gear-row" on:click={() => toggleFilter('objeto_gasto')}>
<div class="gear-check" class:gear-check-on={searchFilters.objeto_gasto}>
{#if searchFilters.objeto_gasto}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</div>
<div class="gear-row-content">
<span class="gear-row-question">¿En qué?</span>
<span class="gear-row-label" class:gear-row-label-on={searchFilters.objeto_gasto}>Objetos de gasto</span>
</div>
<span class="gear-row-count">324</span>
</button>
<button class="gear-row" on:click={() => toggleFilter('rubro')}>
<div class="gear-check" class:gear-check-on={searchFilters.rubro}>
{#if searchFilters.rubro}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</div>
<div class="gear-row-content">
<span class="gear-row-question">¿De dónde?</span>
<span class="gear-row-label" class:gear-row-label-on={searchFilters.rubro}>Rubros de ingreso</span>
</div>
<span class="gear-row-count">324</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Search Results Dropdown -->
{#if $isLoading && $query.length >= 2}
<div class="search-results">
<div class="search-loading">Buscando...</div>
</div>
{:else if searchFocused && filteredResults.length > 0}
<div class="search-results">
{#each filteredResults as item, i}
<button
type="button"
class="search-result-item"
class:result-selected={$selectedIndex === i}
on:click={() => handleSelect(item)}
>
<div class="result-main">
<span class="result-name">{item.nombre}</span>
{#if item.sigla}
<span class="result-sigla">({item.sigla})</span>
{/if}
</div>
<div class="result-meta">
{#if item.codigo}
<span class="result-code">{item.codigo}</span>
{/if}
<span class="result-type" style="color:{getTypeColor(item.tipo)}">{getTypeLabel(item.tipo)}</span>
</div>
</button>
{/each}
</div>
{:else if searchFocused && $query.length >= 2 && filteredResults.length === 0}
<div class="search-results">
<div class="search-no-results">Sin resultados para "{$query}"</div>
</div>
{/if}
{#if $error}
<div class="search-error">{$error}</div>
{/if}
<!-- Mobile settings panel -->
<div class="mobile-settings-panel" class:mobile-settings-panel-open={mobileSettingsOpen}>
<div class="mobile-meta-bar">
<a href="/vista-diaria" class="mobile-meta-item mobile-diario-link">
Ver hoy
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
</a>
<span class="mobile-meta-item mobile-search-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
Viendo 2005 – 2025
</span>
</div>
<div class="mobile-filter-section">
<div class="mobile-filter-title">Buscar</div>
<div class="mobile-filter-bar">
<button class="mobile-filter-chip" class:filter-chip-active={searchFilters.entidad} on:click={() => toggleFilter('entidad')}>
<span class="filter-chip-dot" style="background:#4A8B6E"></span>
Instituciones
</button>
<button class="mobile-filter-chip" class:filter-chip-active={searchFilters.objeto_gasto} on:click={() => toggleFilter('objeto_gasto')}>
<span class="filter-chip-dot" style="background:#C9A751"></span>
Gastos
</button>
<button class="mobile-filter-chip" class:filter-chip-active={searchFilters.rubro} on:click={() => toggleFilter('rubro')}>
<span class="filter-chip-dot" style="background:#8B6E9B"></span>
Ingresos
</button>
</div>
</div>
</div>
<!-- Pills -->
<div class="pills">
<span class="pills-label">Ej:</span>
{#each examples as ex}
{#if ex.href}
<a class="pill" style="color:{ex.color};background:{ex.bg}" href={ex.href}>{ex.label}</a>
{:else}
<button class="pill" style="color:{ex.color};background:{ex.bg}" on:click={() => searchVal = ex.label}>{ex.label}</button>
{/if}
{/each}
</div>
</div>
</div>
</div>
<!-- Scroll hint -->
<div class="scroll-hint anim" style="--d:700ms" class:scroll-hint-hidden={searchFocused && $results.length > 0}>
<svg width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="#B8B5AD" stroke-width="1.2" stroke-linecap="round">
<rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
</svg>
<span>Explora el portal</span>
</div>
</section>
</section>
<!-- ═══ PORTAL DIRECTORY ═══ -->
<section class="directory" data-section="datos">
<div class="directory-inner">
<!-- Left: Title -->
<div class="directory-left">
<div use:reveal={{ id: 'dir-head' }} class="reveal" class:revealed={revealStates['dir-head']}>
<p class="section-tag">LOS DATOS</p>
<h2 class="section-title">Dos formas<br/>de ver el<br/>presupuesto.</h2>
</div>
</div>
<!-- Right: Cards -->
<div class="directory-right">
<!-- Diario -->
<div use:reveal={{ id: 'door-d', delay: 60 }} class="reveal" class:revealed={revealStates['door-d']}>
<div class="door door-warm" class:door-hovered={hDiario} on:mouseenter={() => hDiario = true} on:mouseleave={() => hDiario = false} role="button" tabindex="0">
<div class="door-content">
<div class="door-header">
<div class="door-icon" style="opacity:{hDiario ? 1 : 0.4}">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#4A8B6E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 12 7 12 9 6 12 18 15 9 17 12 21 12"/></svg>
</div>
<div class="door-badge">
<div class="pulse-dot-sm"></div>
<span class="door-badge-text" style="color:#4A8B6E">EN VIVO</span>
</div>
</div>
<h3 class="door-title door-title-dark">Diariamente</h3>
<div class="door-pills">
<span class="door-pill door-pill-green">¿Cuánto se ejecutó hoy?</span>
<span class="door-pill door-pill-green">¿Quién gastó más?</span>
<span class="door-pill door-pill-green">¿Qué cambió desde ayer?</span>
<span class="door-pill door-pill-green">Panorama del presupuesto en curso</span>
<span class="door-pill door-pill-green">Bitácora de movimientos</span>
<span class="door-pill door-pill-green">Descarga de datos diaria</span>
<span class="door-pill door-pill-green">Vista de todo el Estado</span>
<span class="door-pill door-pill-green">Vista por Institución</span>
</div>
</div>
<div class="door-footer">
<span class="door-meta">ACTUALIZADO HACE 2 HORAS</span>
<span class="door-cta" style="color:{hDiario ? '#4A8B6E' : '#6B6860'}">Explorar →</span>
</div>
</div>
</div>
<!-- Archivo -->
<div use:reveal={{ id: 'door-a', delay: 160 }} class="reveal" class:revealed={revealStates['door-a']}>
<div class="door door-warm" class:door-hovered={hArchivo} on:mouseenter={() => hArchivo = true} on:mouseleave={() => hArchivo = false} role="button" tabindex="0">
<div class="door-content">
<div class="door-header">
<div class="door-icon" style="opacity:{hArchivo ? 1 : 0.4}">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#A68A3E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="14" height="14" rx="2"/><path d="M7 6V4.5A1.5 1.5 0 0 1 8.5 3h7A1.5 1.5 0 0 1 17 4.5V6"/><line x1="8" y1="11" x2="14" y2="11"/><line x1="8" y1="14.5" x2="12" y2="14.5"/></svg>
</div>
<span class="door-badge-text" style="color:#A68A3E">2005 — 2025</span>
</div>
<h3 class="door-title door-title-dark">Históricamente</h3>
<div class="door-pills">
<span class="door-pill door-pill-light">Tendencias</span>
<span class="door-pill door-pill-light">Tipos de ingreso</span>
<span class="door-pill door-pill-light">Tipos de gasto</span>
<span class="door-pill door-pill-light">Por institución</span>
<span class="door-pill door-pill-light">Por sector</span>
<span class="door-pill door-pill-light">Por autonomías</span>
<span class="door-pill door-pill-light">Comparar entidades</span>
</div>
</div>
<div class="door-footer">
<span class="door-meta">8M REGISTROS · 7 CLASIFICADORES</span>
<span class="door-cta" style="color:{hArchivo ? '#C9A751' : '#6B6860'}">Explorar →</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ CLASIFICADORES ═══ -->
<section id="clasificadores" class="clasificadores" data-section="clasificadores">
<div class="clas-container">
<!-- Header -->
<div use:reveal={{ id: 'clas-head' }} class="reveal clas-header-section" class:revealed={revealStates['clas-head']}>
<div class="clas-header-row">
<div>
<p class="section-tag">CLASIFICADORES</p>
<h2 class="section-title">Elige cómo explorar el presupuesto</h2>
<p class="clas-subtitle">Navega por cada una de las 2,626 piezas de los 8 clasificadores.</p>
</div>
<a href="/clasificadores" class="clas-ver-todos">Ver todos →</a>
</div>
</div>
<!-- ¿Quién? -->
<div use:reveal={{ id: 'clas-quien', delay: 100 }} class="reveal" class:revealed={revealStates['clas-quien']}>
<div class="clas-group-card">
<div class="clas-group-header">
<div class="clas-group-icon" style="--c:#1C1C1A">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div>
<div class="clas-group-label">¿QUIÉN?</div>
<div class="clas-group-sub">La entidad que gasta o recibe</div>
</div>
</div>
<a href="/clasificadores/institucional" class="widget-card widget-h" class:widget-hover={hWidget === 'quien'} on:mouseenter={() => hWidget = 'quien'} on:mouseleave={() => hWidget = null}>
<div class="widget-left">
<div class="widget-headline">
<span class="widget-num-big">756</span>
<span class="widget-label">instituciones públicas</span>
</div>
<div class="widget-desc">Ministerios, gobernaciones, municipios, empresas públicas, universidades</div>
</div>
<div class="widget-right">
<span class="widget-tech">INSTITUCIONAL</span>
<span class="widget-arrow" class:arrow-show={hWidget === 'quien'}>→</span>
</div>
</a>
</div>
</div>
<!-- Grid: Ingresos + Gastos -->
<div class="clas-main-grid">
<!-- INGRESOS -->
<div use:reveal={{ id: 'clas-ing', delay: 200 }} class="reveal clas-col" class:revealed={revealStates['clas-ing']}>
<div class="clas-group-card">
<div class="clas-group-header">
<div class="clas-group-icon" style="--c:#C9A751">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</div>
<div>
<div class="clas-group-label" style="color:#C9A751">INGRESOS</div>
</div>
</div>
<div class="widgets-col">
{#each WIDGETS_INGRESOS as w}
<a href={w.href} class="widget-card widget-grow" class:widget-hover={hWidget === w.id} on:mouseenter={() => hWidget = w.id} on:mouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-gold">{w.n}</span>
<span class="widget-label">{w.label}</span>
</div>
<p class="widget-desc">{w.d}</p>
<div class="widget-footer">
<span class="widget-tech">{w.t}</span>
<span class="widget-arrow" class:arrow-show={hWidget === w.id}>→</span>
</div>
</a>
{/each}
</div>
</div>
</div>
<!-- GASTOS -->
<div use:reveal={{ id: 'clas-gas', delay: 300 }} class="reveal clas-col" class:revealed={revealStates['clas-gas']}>
<div class="clas-group-card">
<div class="clas-group-header">
<div class="clas-group-icon" style="--c:#4A8B6E">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</div>
<div>
<div class="clas-group-label" style="color:#4A8B6E">GASTOS</div>
</div>
</div>
<!-- Gastos content wrapper -->
<div class="gastos-content">
<!-- ¿En qué? sub-group -->
<div class="clas-subgroup">
<div class="clas-subgroup-header">
<div class="clas-subgroup-icon" style="--c:#4A8B6E">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div>
<div class="clas-subgroup-label">¿EN QUÉ?</div>
</div>
</div>
<div class="widgets-row">
{#each WIDGETS_GASTOS_QUE as w}
<a href={w.href} class="widget-card widget-sm" class:widget-hover={hWidget === w.id} on:mouseenter={() => hWidget = w.id} on:mouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">{w.n}</span>
<span class="widget-label">{w.label}</span>
</div>
<p class="widget-desc widget-desc-sm">{w.d}</p>
<div class="widget-footer">
<span class="widget-tech">{w.t}</span>
<span class="widget-arrow" class:arrow-show={hWidget === w.id}>→</span>
</div>
</a>
{/each}
</div>
</div>
<!-- Bottom: Para qué + Dónde -->
<div class="widgets-row">
<!-- ¿Para qué? -->
<div class="clas-subgroup">
<div class="clas-subgroup-header">
<div class="clas-subgroup-icon" style="--c:#4A8B6E">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
</div>
<div>
<div class="clas-subgroup-label">¿PARA QUÉ?</div>
</div>
</div>
<a href="/clasificadores/funcional" class="widget-card widget-full" class:widget-hover={hWidget === 'finalidad'} on:mouseenter={() => hWidget = 'finalidad'} on:mouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">132</span>
<span class="widget-label">finalidades y funciones</span>
</div>
<p class="widget-desc">Educación, salud, defensa</p>
<div class="widget-footer">
<span class="widget-tech">Finalidad</span>
<span class="widget-arrow" class:arrow-show={hWidget === 'finalidad'}>→</span>
</div>
</a>
</div>
<!-- ¿Dónde? -->
<div class="clas-subgroup">
<div class="clas-subgroup-header">
<div class="clas-subgroup-icon" style="--c:#4A8B6E">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
</div>
<div>
<div class="clas-subgroup-label">¿DÓNDE?</div>
</div>
</div>
<a href="/clasificadores/geografico" class="widget-card widget-full" class:widget-hover={hWidget === 'donde'} on:mouseenter={() => hWidget = 'donde'} on:mouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">352</span>
<span class="widget-label">ubicaciones geográficas</span>
</div>
<p class="widget-desc">Departamentos, municipios</p>
<div class="widget-footer">
<span class="widget-tech">Geográfico</span>
<span class="widget-arrow" class:arrow-show={hWidget === 'donde'}>→</span>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ HISTORIAS ═══ -->
<section id="historias" class="historias" data-section="historias">
<div class="container">
<div use:reveal={{ id: 'hist-head' }} class="reveal" class:revealed={revealStates['hist-head']}>
<div class="hist-header">
<div>
<div class="hist-tag-row">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6B6860" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<p class="section-tag">HISTORIAS</p>
</div>
<h2 class="section-title">Los datos, contados.</h2>
<p class="hist-subtitle">Investigaciones y análisis narrativos sobre el presupuesto público.</p>
</div>
<a href="/historias" class="gold-link">Ver todas →</a>
</div>
</div>
<div class="hist-grid">
{#each HISTORIAS as h, i}
<div use:reveal={{ id: `hist-${i}`, delay: 60 + i * 80 }} class="reveal hist-cell" class:revealed={revealStates[`hist-${i}`]} class:hist-tall={h.tall}>
<div class="hist-card" class:hist-card-hovered={hHist === i} on:mouseenter={() => hHist = i} on:mouseleave={() => hHist = null} role="button" tabindex="0" style="--card-color:{h.color}">
<div class="hist-accent" style="background:{h.color};transform:scaleX({hHist === i ? 1 : 0})"></div>
<div class="hist-content">
<span class="hist-tag" style="color:{h.color}">{h.tag}</span>
<h4 class="hist-title">{h.title}</h4>
<p class="hist-desc">{h.desc}</p>
</div>
<div class="hist-cta" style="color:{hHist === i ? h.color : '#6B6860'}">
Leer historia <span class="hist-arrow" class:hist-arrow-moved={hHist === i}>→</span>
</div>
</div>
</div>
{/each}
</div>
</div>
</section>
<!-- ═══ VISUALIZACIONES ═══ -->
<section id="visualizaciones" class="visualizaciones" data-section="visualizaciones">
<div class="container">
<div use:reveal={{ id: 'vis-head' }} class="reveal" class:revealed={revealStates['vis-head']}>
<div class="vis-header">
<div>
<div class="vis-tag-row">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6B6860" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<p class="section-tag">VISUALIZACIONES</p>
</div>
<h2 class="section-title">Explora los datos.</h2>
<p class="vis-subtitle">Herramientas interactivas para navegar el presupuesto.</p>
</div>
<a href="/visualizaciones" class="gold-link">Ver todas →</a>
</div>
</div>
<div class="vis-grid-simple">
{#each VISUALIZACIONES as v, i}
<div use:reveal={{ id: `vis-${i}`, delay: 60 + i * 80 }} class="reveal" class:revealed={revealStates[`vis-${i}`]}>
{#if v.href}
<a href={v.href} class="vis-card vis-card-link" class:vis-card-hovered={hVis === i} on:mouseenter={() => hVis = i} on:mouseleave={() => hVis = null}>
<div class="vis-accent" style="background:{v.color};transform:scaleX({hVis === i ? 1 : 0})"></div>
<div>
<span class="vis-type" style="background:color-mix(in srgb, {v.color} 12%, transparent);color:{v.color}">{v.type}</span>
<h4 class="vis-title">{v.title}</h4>
<p class="vis-desc">{v.desc}</p>
</div>
<div class="vis-cta" style="color:{hVis === i ? v.color : '#6B6860'}">
Explorar <span class="vis-arrow" class:vis-arrow-moved={hVis === i}>→</span>
</div>
</a>
{:else}
<div class="vis-card vis-card-disabled" class:vis-card-hovered={hVis === i} on:mouseenter={() => hVis = i} on:mouseleave={() => hVis = null} role="button" tabindex="0">
<div class="vis-accent" style="background:{v.color};transform:scaleX({hVis === i ? 1 : 0})"></div>
<div>
<span class="vis-type" style="background:color-mix(in srgb, {v.color} 12%, transparent);color:{v.color}">{v.type}</span>
<h4 class="vis-title">{v.title}</h4>
<p class="vis-desc">{v.desc}</p>
</div>
<div class="vis-cta" style="color:#6B6860">
Próximamente
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</section>
<!-- ═══ MANIFIESTO ═══ -->
<section class="manifiesto" data-section="manifiesto">
<div class="dot-pattern"></div>
<div class="manifiesto-inner">
<div use:reveal={{ id: 'man' }} class="reveal" class:revealed={revealStates['man']}>
<p class="section-tag-light">POR QUÉ EXISTE ESTO</p>
<h2 class="manifiesto-title">El presupuesto público no es de los gobiernos.<br/><span class="gold">Es de todos.</span></h2>
</div>
</div>
</section>
<!-- ═══ DESCARGAS ═══ -->
<section id="descargas" class="descargas" data-section="descargas">
<div class="descargas-inner">
<div use:reveal={{ id: 'desc' }} class="reveal" class:revealed={revealStates['desc']}>
<h2 class="descargas-title">Descarga los datos.</h2>
<div class="descargas-formats">
<span class="format-pill">.csv</span>
<span class="format-pill">.parquet</span>
<span class="format-pill">.gpkg</span>
</div>
<a href="/descargas" class="descargas-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12"/><polyline points="8 12 12 16 16 12"/><path d="M4 18h16"/></svg>
Ir a página de descargas
</a>
</div>
</div>
</section>
<!-- ═══ FOOTER ═══ -->
<footer>
<div class="footer-inner">
<div class="footer-org">MINISTERIO DE ECONOMÍA Y FINANZAS PÚBLICAS</div>
<div class="footer-right">DATOS ABIERTOS · 2005 — 2026</div>
</div>
</footer>
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap');
:global(*){margin:0;padding:0;box-sizing:border-box}
:global(html){width:100%;height:100%;margin:0;padding:0;overflow-x:hidden;background:#1C1C1A;scroll-behavior:smooth}
:global(body){width:100%;min-height:100%;margin:0;padding:0;overflow-x:hidden;background:#1C1C1A}
:global(::selection){background:#C9A751;color:#1C1C1A}
:global(::-webkit-scrollbar){width:5px}
:global(::-webkit-scrollbar-track){background:#1C1C1A}
:global(::-webkit-scrollbar-thumb){background:#444;border-radius:3px}
:global(a){color:inherit;text-decoration:none}
@keyframes pulse{0%,100%{opacity:0.35}50%{opacity:1}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes scrollHint{0%,100%{transform:translateY(0);opacity:0.6}50%{transform:translateY(5px);opacity:1}}
@keyframes heartbeat{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:48}}
.page{--serif:'Qanelas',system-ui,sans-serif;--sans:'Qanelas',system-ui,sans-serif;--mono:'JetBrains Mono',monospace;font-family:var(--serif);color:#1C1C1A;width:100%;min-width:100%;overflow-x:hidden;scroll-snap-type:y mandatory;overflow-y:scroll;height:100vh}
/* Section Navigation Dots */
.section-dots{position:fixed;right:24px;top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;gap:12px;background:rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:12px 8px;border-radius:20px}
.section-dot{width:10px;height:10px;border-radius:50%;border:1.5px solid rgba(100,100,100,0.4);background:rgba(150,150,150,0.2);cursor:pointer;transition:all 0.3s;padding:0;position:relative}
.section-dot:hover{border-color:rgba(100,100,100,0.6);background:rgba(150,150,150,0.4);transform:scale(1.2)}
.section-dot-active{background:#C9A751;border-color:#C9A751}
.section-dot-active:hover{transform:scale(1.2)}
.section-dot-label{position:absolute;right:20px;top:50%;transform:translateY(-50%);font-family:var(--mono);font-size:10px;letter-spacing:0.05em;color:#6B6860;white-space:nowrap;opacity:0;transition:opacity 0.2s;pointer-events:none}
.section-dot:hover .section-dot-label{opacity:1}
.gold{color:#C9A751}
.container{max-width:1060px;margin:0 auto}
/* Reveal */
.reveal{opacity:0;transform:translateY(28px);transition:all 0.8s cubic-bezier(0.16,1,0.3,1)}
.revealed{opacity:1!important;transform:translateY(0)!important}
.anim{opacity:0;transform:translateY(18px);transition:all 0.8s cubic-bezier(0.16,1,0.3,1);transition-delay:var(--d,0ms)}
.mounted .anim{opacity:1;transform:translateY(0)}
/* ── NAV ── */
nav{background:#1C1C1A;padding:12px 48px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.nav-left{display:flex;align-items:center}
.nav-right{display:flex;align-items:center;gap:24px}
.nav-link{font-family:var(--sans);font-size:14px;color:#B8B5AD;letter-spacing:0.01em;font-weight:400;transition:color 0.2s}
.nav-link:hover{color:#F5F0E8}
.nav-link-icon{display:flex;align-items:center;gap:5px}
.heartbeat-line{animation:heartbeat 3s ease-in-out infinite}
.nav-date{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:9px;letter-spacing:0.06em;color:#B8B5AD;font-weight:400}
.date-label{color:#9B9890}
.date-sep{margin:0 3px;opacity:0.3}
.pulse-dot{width:5px;height:5px;border-radius:50%;background:#4A8B6E;animation:pulse 2s ease-in-out infinite}
.pulse-dot-sm{width:6px;height:6px;border-radius:50%;background:#4A8B6E;animation:pulse 2s ease-in-out infinite;box-shadow:0 0 8px rgba(74,139,110,0.37)}
.nav-sep{width:1px;height:16px;background:#2A2A27}
.nav-logo{height:88px;display:flex;align-items:center;flex-shrink:0}
.nav-logo img{height:100%;width:auto;object-fit:contain}
/* ── HERO ── */
.landing-screen{height:100vh;background:#1C1C1A;display:flex;flex-direction:column;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden}
.hero{background:#1C1C1A;color:#F5F0E8;flex:1;display:flex;flex-direction:column;justify-content:center;padding:40px 48px 32px;position:relative;overflow:hidden}
.dot-pattern{position:absolute;inset:0;opacity:0.02;background-image:radial-gradient(circle at 1px 1px,#B8B5AD 0.5px,transparent 0);background-size:40px 40px}
.dot-pattern-sm{position:absolute;inset:0;opacity:0.015;background-image:radial-gradient(circle at 1px 1px,#fff 0.5px,transparent 0);background-size:32px 32px}
.hero-inner{max-width:1060px;margin:0 auto;width:100%;position:relative;margin-top:-80px;text-align:center}
.hero-identity{margin-bottom:20px}
.hero-search{display:flex;justify-content:center;position:relative;z-index:50}
.hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD}
.hero-title{font-size:clamp(44px,5.5vw,72px);font-weight:900;line-height:1.02;letter-spacing:-0.035em;max-width:720px;margin:14px auto 0}
.hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#A5A29A;max-width:100%;margin-top:14px;line-height:1.6}
.hero-sub-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(165,162,154,0.5);transition:all 0.2s;padding-bottom:1px}
.hero-sub-link:hover{color:#4A8B6E;border-bottom-color:#4A8B6E}
.hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1}
.stat-num{color:#F5F0E8}
.stat-dot{margin:0 10px;opacity:0.3}
.stat-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(184,181,173,0.4);transition:all 0.2s;padding-bottom:1px}
.stat-link:hover{color:#C9A751;border-bottom-color:#C9A751}
/* Search */
.search-wrap{max-width:960px;width:100%;margin:0 auto;position:relative;z-index:9999}
.search-bar{display:flex;align-items:center;background:rgba(255,255,255,0.04);border-radius:16px;padding:8px 14px 8px 8px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1);box-shadow:0 0 0 1px rgba(201,167,81,0.12),0 8px 32px rgba(0,0,0,0.25);position:relative}
.search-focused{background:rgba(255,255,255,0.07);box-shadow:0 0 0 1.5px rgba(201,167,81,0.35),0 8px 40px rgba(0,0,0,0.3)}
.search-has-results{border-radius:16px 16px 0 0}
.search-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;padding:0 4px}
.search-meta-item{display:flex;align-items:center;gap:7px;font-family:var(--sans);font-size:13px;font-weight:500;padding:10px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
.search-meta-item svg{display:block;flex-shrink:0}
.search-badge{color:#4A8B6E;background:rgba(74,139,110,0.12)}
.search-diario-link{color:#C9A751;background:rgba(201,167,81,0.12)}
.search-diario-link:hover{background:rgba(201,167,81,0.22);color:#D4B65E}
.search-diario-link svg{transition:transform 0.2s}
.search-diario-link:hover svg{transform:translateX(3px)}
.search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0}
.search-icon{flex-shrink:0;transition:stroke 0.3s}
.search-input-wrap{flex:1;position:relative;display:flex;align-items:center}
.search-input-wrap input{width:100%;background:transparent;border:none;outline:none;color:#F5F0E8;font-size:16px;font-family:var(--sans);padding:12px 12px 12px 10px;caret-color:#C9A751}
.search-placeholder{position:absolute;left:10px;top:50%;transform:translateY(-50%);display:flex;align-items:center;pointer-events:none}
.blink-cursor{width:1.5px;height:18px;margin-right:1px;animation:blink 1s step-end infinite;border-radius:1px;flex-shrink:0}
.cursor-diario{background:#4A8B6E}
.cursor-archivo{background:#C9A751}
.placeholder-text{font-family:var(--sans);font-size:16px;color:#B8B5AD}
.placeholder-mobile{display:none}
.gear-btn{width:38px;height:38px;border-radius:10px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.06);cursor:pointer;transition:all 0.3s;flex-shrink:0;margin-left:8px}
.gear-btn svg{transition:transform 0.3s ease}
.gear-btn:hover svg{transform:rotate(45deg)}
.gear-btn:hover,.gear-active{background:rgba(255,255,255,0.12)}
.gear-wrap{position:relative}
.gear-desktop{display:block}
.gear-menu{position:absolute;top:calc(100% + 6px);right:0;background:rgba(28,28,26,0.92);backdrop-filter:blur(32px) saturate(1.2);-webkit-backdrop-filter:blur(32px) saturate(1.2);border-radius:16px;min-width:320px;z-index:9999;box-shadow:0 12px 48px rgba(0,0,0,0.5);padding:4px;animation:gearFadeIn 0.15s ease-out}
@keyframes gearFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
.gear-menu-title{font-family:var(--sans);font-size:11px;font-weight:400;color:#6B6860;padding:10px 14px 6px;letter-spacing:0.02em}
.gear-row{display:flex;align-items:center;gap:12px;width:100%;background:transparent;border:none;padding:12px 14px;border-radius:12px;cursor:pointer;transition:background 0.15s;text-align:left}
.gear-row:hover{background:rgba(255,255,255,0.04)}
.gear-check{width:18px;height:18px;border-radius:6px;border:1.5px solid rgba(255,255,255,0.15);background:transparent;display:flex;align-items:center;justify-content:center;transition:all 0.2s;flex-shrink:0;color:transparent}
.gear-check-on{border-color:transparent;background:#C9A751;color:#1C1C1A}
.gear-row-content{flex:1;display:flex;align-items:baseline;gap:8px}
.gear-row-question{font-family:var(--sans);font-size:12px;color:#5A5850;font-weight:400}
.gear-row-label{font-family:var(--sans);font-size:14px;color:#8B8880;transition:color 0.15s}
.gear-row-label-on{color:#F5F0E8}
.gear-row-count{font-family:var(--mono);font-size:10px;color:#4A4840}
/* Mobile settings button (hidden on desktop) */
.mobile-settings-btn{display:none;width:40px;height:40px;border-radius:10px;background:rgba(255,255,255,0.06);border:none;cursor:pointer;align-items:center;justify-content:center;position:relative;transition:all 0.3s;flex-shrink:0}
.mobile-settings-btn:hover,.mobile-settings-open{background:rgba(255,255,255,0.12)}
.mobile-mode-indicator{position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%}
/* Mobile settings panel (hidden on desktop) */
.mobile-settings-panel{display:none;overflow:hidden;max-height:0;opacity:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
.mobile-settings-panel-open{max-height:300px;opacity:1;margin-top:12px}
.mobile-meta-bar{display:flex;gap:10px;padding:4px 0}
.mobile-meta-item{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--sans);font-size:13px;font-weight:500;padding:12px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
.mobile-meta-item svg{flex-shrink:0}
.mobile-search-badge{color:#4A8B6E;background:rgba(74,139,110,0.12)}
.mobile-diario-link{color:#C9A751;background:rgba(201,167,81,0.12)}
.mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(201,167,81,0.22)}
.mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)}
.mobile-filter-title{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:0.1em;color:#6B6860;text-transform:uppercase;margin-bottom:8px}
.mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap}
.mobile-filter-chip{display:flex;align-items:center;gap:6px;padding:8px 12px;border-radius:20px;background:rgba(0,0,0,0.2);border:1px solid rgba(255,255,255,0.06);cursor:pointer;transition:all 0.2s;font-family:var(--sans);font-size:13px;color:#6B6860}
.mobile-filter-chip.filter-chip-active{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.15);color:#F5F0E8}
.filter-chip-dot{width:6px;height:6px;border-radius:50%;opacity:0.4;transition:opacity 0.2s}
.mobile-filter-chip.filter-chip-active .filter-chip-dot{opacity:1}
/* Search clear button */
.search-clear{background:none;border:none;cursor:pointer;color:#6B6860;padding:4px;display:flex;align-items:center;justify-content:center;transition:color 0.2s;flex-shrink:0}
.search-clear:hover{color:#F5F0E8}
/* Search Results Dropdown */
.search-results{position:absolute;top:100%;left:0;right:0;background:rgba(28,28,26,0.98);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.1);border-top:none;border-radius:0 0 16px 16px;max-height:320px;overflow-y:auto;z-index:9999}
.search-loading,.search-no-results{padding:16px 20px;font-family:var(--sans);font-size:14px;color:#9B9890;text-align:center}
.search-error{margin-top:8px;font-family:var(--sans);font-size:13px;color:#C44B3F;text-align:center}
.search-result-item{width:100%;text-align:left;background:none;border:none;border-top:1px solid rgba(255,255,255,0.06);padding:14px 20px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;transition:background 0.2s}
.search-result-item:first-child{border-top:none}
.search-result-item:hover,.result-selected{background:rgba(255,255,255,0.06)}
.result-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0}
.result-name{font-family:var(--sans);font-size:14px;color:#F5F0E8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.result-sigla{font-family:var(--sans);font-size:12px;color:#9B9890;flex-shrink:0}
.result-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:12px}
.result-code{font-family:var(--mono);font-size:10px;color:#9B9890;background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px}
.result-type{font-family:var(--mono);font-size:9px;letter-spacing:0.1em;font-weight:600}
.pills{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;align-items:center;justify-content:center}
.pills-label{font-family:var(--mono);font-size:11px;letter-spacing:0.1em;color:#B8B5AD;margin-right:2px}
.pill{font-family:var(--mono);font-size:12px;font-weight:300;letter-spacing:0.03em;border:none;border-radius:6px;padding:5px 12px;cursor:pointer;transition:all 0.25s;line-height:1.4;position:relative;overflow:hidden;text-decoration:none}
.pill::before{content:'';position:absolute;inset:0;background:rgba(255,255,255,0.12);opacity:0;transition:opacity 0.25s}
.pill:hover::before{opacity:1}
.pill:hover{transform:translateY(-1px)}
.scroll-hint{position:absolute;bottom:28px;left:0;right:0;margin:0 auto;width:fit-content;display:flex;flex-direction:column;align-items:center;gap:10px;animation:scrollHint 2.5s ease-in-out infinite;z-index:1;transition:opacity 0.3s}
.scroll-hint-hidden{opacity:0;pointer-events:none}
.scroll-hint span{font-family:var(--mono);font-size:10px;letter-spacing:0.2em;color:#B8B5AD;opacity:0.7}
/* ── DIRECTORY ── */
.directory{background:#f5f5f7;min-height:100vh;display:flex;align-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:48px}
.directory-inner{display:grid;grid-template-columns:280px 1fr;gap:48px;width:100%;max-width:1200px;margin:0 auto;align-items:center}
.directory-left{}
.directory-right{display:flex;flex-direction:column;gap:20px}
.section-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.2em;color:#6B6860;margin-bottom:10px}
.section-tag-light{font-family:var(--mono);font-size:12px;letter-spacing:0.2em;color:#B8B5AD}
.section-title{font-size:clamp(40px,4.5vw,56px);font-weight:900;letter-spacing:-0.025em;line-height:1.05;color:#1C1C1A}
.section-title-light{font-size:clamp(32px,4vw,46px);font-weight:900;letter-spacing:-0.02em;line-height:1.08;color:#F5F0E8}
/* Door cards */
.door{border-radius:24px;padding:44px 40px 40px;cursor:pointer;transition:all 0.5s cubic-bezier(0.16,1,0.3,1);position:relative;overflow:hidden;display:flex;flex-direction:column;justify-content:space-between;min-height:260px}
.door-hovered{transform:translateY(-4px)}
.door-dark{background:#1C1C1A;color:#F5F0E8;box-shadow:none}
.door-dark.door-hovered{box-shadow:0 24px 64px rgba(0,0,0,0.2)}
.door-warm{background:#ffffff;color:#1C1C1A;box-shadow:none}
.door-warm.door-hovered{box-shadow:0 24px 64px rgba(201,167,81,0.12)}
.door-accent{position:absolute;top:0;left:0;right:0;height:3px;transform-origin:left;transition:transform 0.6s cubic-bezier(0.16,1,0.3,1)}
.door-content{position:relative}
.door-header{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.door-icon{transition:opacity 0.3s}
.door-badge{display:flex;align-items:center;gap:6px}
.door-badge-text{font-family:var(--mono);font-size:11px;letter-spacing:0.16em;font-weight:500}
.door-title{font-size:clamp(28px,3vw,38px);font-weight:900;letter-spacing:-0.02em;line-height:1.08;color:#F5F0E8}
.door-title-dark{color:#1C1C1A}
.door-desc{font-family:var(--sans);font-size:17px;color:#6B6860;line-height:1.55;margin-top:10px;max-width:480px}
.door-desc-light{color:#9B9890}
.door-pills{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.door-pill{font-family:var(--sans);font-size:16px;padding:10px 16px;border-radius:8px;line-height:1.3}
.door-pill-dark{background:rgba(74,139,110,0.12);color:#8ac4a8;font-weight:500}
.door-pill-light{background:rgba(166,138,62,0.1);color:#a68a3e;font-weight:500}
.door-pill-green{background:rgba(74,139,110,0.1);color:#3d7a5e;font-weight:500}
.door-footer{position:relative;display:flex;justify-content:space-between;align-items:center;margin-top:24px}
.door-meta{font-family:var(--mono);font-size:11px;letter-spacing:0.1em;color:#4A4843}
.door-meta-light{color:#B8B5AD}
.door-cta{font-family:var(--sans);font-size:16px;font-weight:400;transition:color 0.3s}
/* ── CLASIFICADORES ── */
.clasificadores{background:#e8e8ea;color:#1C1C1A;padding:48px;height:100vh;display:flex;flex-direction:column;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden}
.clas-container{max-width:1100px;margin:0 auto;width:100%;max-height:calc(100vh - 96px);display:flex;flex-direction:column}
.clas-header-section{margin-bottom:24px;flex-shrink:0}
.clas-header-row{display:flex;justify-content:space-between;align-items:flex-end}
.clas-subtitle{font-family:var(--sans);font-size:17px;color:#6B6860;margin-top:10px}
.clas-ver-todos{font-family:var(--sans);font-size:15px;font-weight:500;color:#1C1C1A;transition:color 0.2s}
.clas-ver-todos:hover{color:#4A8B6E}
/* Group cards */
.clas-group-card{background:#f2f2f4;border:1px solid rgba(0,0,0,0.04);border-radius:20px;padding:16px;margin-bottom:12px;height:100%;display:flex;flex-direction:column;transition:all 0.3s}
/* Parent reacts to child hover */
.clas-group-card:has(.widget-card:hover){background:#efefef;border-color:rgba(0,0,0,0.06)}
.clas-group-card:has(.widget-card:hover) > .clas-group-header .clas-group-label{transform:scale(1.05);transform-origin:left}
/* Parent hover highlights children */
.clas-group-card:hover .widget-card{background:#fdfdfd;border-color:rgba(0,0,0,0.05)}
.clas-group-card:hover .clas-subgroup{background:#f5f5f7}
.gastos-content{display:flex;flex-direction:column;flex:1;gap:10px}
.gastos-content .clas-subgroup{flex:1;margin-bottom:0}
.gastos-content > .widgets-row{flex:1}
.clas-group-header{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.clas-group-icon{width:32px;height:32px;border-radius:10px;background:color-mix(in srgb, var(--c) 10%, transparent);border:1px solid color-mix(in srgb, var(--c) 15%, transparent);display:flex;align-items:center;justify-content:center;color:var(--c);flex-shrink:0}
.clas-group-label{font-family:var(--mono);font-size:12px;letter-spacing:0.15em;font-weight:600;color:#1C1C1A;transition:transform 0.3s cubic-bezier(0.16,1,0.3,1)}
.clas-group-sub{font-family:var(--sans);font-size:13px;color:#6B6860;margin-top:2px}
/* Subgroup */
.clas-subgroup{background:#f7f7f9;border:1px solid rgba(0,0,0,0.03);border-radius:14px;padding:12px;margin-bottom:10px;display:flex;flex-direction:column;transition:all 0.3s}
/* Subgroup parent reacts to child hover */
.clas-subgroup:has(.widget-card:hover){background:#f3f3f5;border-color:rgba(0,0,0,0.05)}
.clas-subgroup:has(.widget-card:hover) > .clas-subgroup-header .clas-subgroup-label{transform:scale(1.05);transform-origin:left}
/* Subgroup hover highlights children */
.clas-subgroup:hover .widget-card{background:#fdfdfd;border-color:rgba(0,0,0,0.05)}
.clas-subgroup .widgets-row{flex:1}
.clas-subgroup-header{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.clas-subgroup-icon{width:32px;height:32px;border-radius:10px;background:color-mix(in srgb, var(--c) 10%, transparent);border:1px solid color-mix(in srgb, var(--c) 15%, transparent);display:flex;align-items:center;justify-content:center;color:var(--c);flex-shrink:0}
.clas-subgroup-label{font-family:var(--mono);font-size:12px;letter-spacing:0.15em;font-weight:600;color:#1C1C1A;transition:transform 0.3s cubic-bezier(0.16,1,0.3,1)}
.clas-subgroup-tag{font-family:var(--sans);font-size:13px;color:#6B6860;margin-top:2px}
/* Main grid */
.clas-main-grid{display:grid;grid-template-columns:1fr 2fr;gap:12px;align-items:stretch;flex:1;min-height:0}
.clas-col{display:flex;flex-direction:column}
.clas-col .clas-group-card{flex:1}
/* Widget cards */
.widget-card{display:block;background:#fafafa;border:1px solid rgba(0,0,0,0.04);border-radius:14px;padding:14px;cursor:pointer;text-decoration:none;color:#1C1C1A;transition:all 0.35s cubic-bezier(0.16,1,0.3,1);box-shadow:0 1px 3px rgba(0,0,0,0.02)}
.widget-hover{background:#ffffff;border-color:rgba(0,0,0,0.06);transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,0.05)}
.widget-h{display:flex;justify-content:space-between;align-items:center}
.widget-left{flex:1}
.widget-right{display:flex;align-items:center;gap:12px;flex-shrink:0}
.widget-grow{flex:1}
.widget-sm{padding:12px}
.widget-full{flex:1}
.widgets-col{display:flex;flex-direction:column;gap:8px;flex:1}
.widgets-col .widget-card{flex:1}
.widgets-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;flex:1}
.widgets-row .widget-card{display:flex;flex-direction:column;justify-content:space-between}
.widget-headline{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}
.widget-num-big{font-family:var(--mono);font-size:24px;font-weight:700;line-height:1}
.widget-num-gold{color:#a68a3e}
.widget-num-green{color:#4A8B6E}
.widget-label{font-family:var(--sans);font-size:14px;font-weight:700;color:#1C1C1A;line-height:1.2}
.widget-desc{font-family:var(--sans);font-size:14px;color:#6B6860;line-height:1.4;font-weight:400}
.widget-desc-sm{font-size:13px}
.widget-footer{display:flex;justify-content:space-between;align-items:center;margin-top:8px}
.widget-tech{font-family:var(--mono);font-size:9px;letter-spacing:0.1em;color:#9B9890;text-transform:uppercase}
.widget-arrow{font-family:var(--sans);font-size:14px;color:transparent;transform:translateX(-6px);transition:all 0.35s cubic-bezier(0.16,1,0.3,1)}
.arrow-show{color:#1C1C1A;transform:translateX(0)}
/* ── HISTORIAS ── */
.historias{background:#1C1C1A;padding:80px 48px;color:#F5F0E8;min-height:100vh;display:flex;flex-direction:column;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always}
.historias .container{width:100%;max-width:1060px;margin:0 auto}
.hist-header{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:36px}
.hist-tag-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.hist-tag-row svg{stroke:#9B9890}
.hist-tag-row .section-tag{color:#9B9890}
.historias .section-title{color:#F5F0E8}
.hist-subtitle{font-family:var(--sans);font-size:17px;color:#9B9890;margin-top:10px}
.hist-grid{display:grid;grid-template-columns:repeat(3, 1fr);grid-auto-rows:minmax(120px, auto);gap:16px}
.hist-cell{grid-row:span 1}
.hist-tall{grid-row:span 2}
.hist-card{background:color-mix(in srgb, var(--card-color) 15%, #1C1C1A);border:1px solid color-mix(in srgb, var(--card-color) 25%, transparent);border-radius:18px;padding:28px 24px 24px;cursor:pointer;transition:all 0.5s cubic-bezier(0.16,1,0.3,1);position:relative;overflow:hidden;height:100%;display:flex;flex-direction:column;justify-content:space-between}
.hist-card-hovered{background:color-mix(in srgb, var(--card-color) 22%, #1C1C1A);border-color:color-mix(in srgb, var(--card-color) 35%, transparent);transform:translateY(-3px)}
.hist-accent{position:absolute;top:0;left:0;right:0;height:2px;transform-origin:left;transition:transform 0.5s cubic-bezier(0.16,1,0.3,1)}
.hist-content{flex:1}
.hist-tag{font-family:var(--mono);font-size:10px;letter-spacing:0.14em;font-weight:500}
.hist-title{font-family:var(--serif);font-size:18px;font-weight:800;margin-top:12px;line-height:1.25;letter-spacing:-0.01em;color:#F5F0E8}
.hist-tall .hist-title{font-size:22px}
.hist-desc{font-family:var(--sans);font-size:14px;color:#9B9890;line-height:1.5;margin-top:10px}
.hist-cta{font-family:var(--sans);font-size:14px;margin-top:20px;transition:color 0.3s;display:flex;align-items:center;gap:3px;color:#6B6860}
.hist-arrow{transition:transform 0.3s;display:inline-block}
.hist-arrow-moved{transform:translateX(3px)}
/* ── VISUALIZACIONES ── */
.visualizaciones{background:#f5f5f7;padding:80px 48px;color:#1C1C1A;min-height:100vh;display:flex;flex-direction:column;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always}
.visualizaciones .container{width:100%;max-width:1060px;margin:0 auto}
.vis-header{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:36px}
.vis-tag-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.vis-subtitle{font-family:var(--sans);font-size:17px;color:#6B6860;margin-top:10px}
.gold-link{font-family:var(--sans);font-size:15px;color:#C9A751;font-weight:600}
.vis-grid-simple{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px}
.vis-card{background:#fafafc;border-radius:18px;padding:28px 24px 24px;cursor:pointer;transition:all 0.5s cubic-bezier(0.16,1,0.3,1);position:relative;overflow:hidden;height:100%;display:flex;flex-direction:column;justify-content:space-between}
.vis-card-hovered{transform:translateY(-2px);box-shadow:0 16px 40px rgba(0,0,0,0.05)}
.vis-accent{position:absolute;top:0;left:0;right:0;height:2px;transform-origin:left;transition:transform 0.5s cubic-bezier(0.16,1,0.3,1)}
.vis-type{font-family:var(--mono);font-size:10px;letter-spacing:0.1em;font-weight:500;padding:5px 10px;border-radius:6px;display:inline-block}
.vis-title{font-family:var(--serif);font-size:18px;font-weight:800;margin-top:12px;line-height:1.25;letter-spacing:-0.01em;color:#1C1C1A}
.vis-desc{font-family:var(--sans);font-size:14px;color:#6B6860;line-height:1.5;margin-top:10px}
.vis-cta{font-family:var(--sans);font-size:14px;margin-top:20px;transition:color 0.3s;display:flex;align-items:center;gap:3px;color:#6B6860}
.vis-arrow{transition:transform 0.3s;display:inline-block}
.vis-arrow-moved{transform:translateX(3px)}
.vis-card-link{text-decoration:none;color:inherit}
.vis-card-disabled{opacity:0.6;cursor:default}
.vis-card-disabled:hover{transform:none;box-shadow:none}
/* ── MANIFIESTO ── */
.manifiesto{background:#1C1C1A;color:#F5F0E8;padding:96px 48px;position:relative;min-height:100vh;display:flex;flex-direction:column;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always}
.manifiesto-inner{max-width:640px;width:100%;margin:0 auto;position:relative;text-align:center}
.manifiesto-title{font-size:clamp(36px,4.5vw,56px);font-weight:900;line-height:1.08;letter-spacing:-0.025em;margin-top:18px}
.manifiesto-text{font-family:var(--sans);font-size:17px;color:#9B9890;margin-top:20px;line-height:1.7}
/* ── DESCARGAS ── */
.descargas{background:#1C1C1A;padding:80px 48px;color:#F5F0E8;min-height:100vh;display:flex;flex-direction:column;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always}
.descargas-inner{max-width:640px;width:100%;margin:0 auto;text-align:center}
.descargas-title{font-size:clamp(32px,4vw,48px);font-weight:900;line-height:1.1;letter-spacing:-0.02em;color:#F5F0E8}
.descargas-formats{display:flex;justify-content:center;gap:12px;margin-top:24px}
.format-pill{font-family:var(--mono);font-size:14px;letter-spacing:0.05em;color:#9B9890;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);padding:8px 16px;border-radius:8px}
.descargas-link{display:inline-flex;align-items:center;gap:8px;margin-top:32px;font-family:var(--sans);font-size:15px;color:#C9A751;transition:all 0.3s}
.descargas-link:hover{color:#d4b45a;gap:10px}
/* ── FOOTER ── */
footer{background:#1C1C1A;padding:40px 48px;border-top:1px solid #2A2A27}
.footer-inner{max-width:1060px;margin:0 auto;display:flex;justify-content:space-between;align-items:center}
.footer-org{font-family:var(--mono);font-size:12px;letter-spacing:0.12em;color:#6B6860}
.footer-unit{font-family:var(--sans);font-size:13px;color:#6B6860;margin-top:3px}
.footer-right{font-family:var(--mono);font-size:12px;letter-spacing:0.1em;color:#6B6860}
/* ══════════════════════════════════════════════════════════════════════════
RESPONSIVE - TABLET (768px)
══════════════════════════════════════════════════════════════════════════ */
@media(max-width:768px){
.page{scroll-snap-type:y proximity}
.landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal}
/* Section dots - bottom horizontal bar */
.section-dots{position:fixed;right:auto;left:50%;top:auto;bottom:20px;transform:translateX(-50%);flex-direction:row;gap:10px;padding:12px 20px;border-radius:24px;background:rgba(28,28,26,0.7);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.1);z-index:1000}
.section-dot{width:8px;height:8px}
.section-dot-label{display:none}
/* Nav */
nav{padding:12px 24px}
.nav-logo{height:60px}
/* Hero */
.hero{padding:32px 24px}
.hero-inner{margin-top:0}
.hero-title{font-size:clamp(36px,8vw,48px)}
.hero-stats{font-size:11px;line-height:1.6}
.stat-dot{margin:0 6px}
/* Search bar - mobile compact */
.search-bar{flex-wrap:nowrap;padding:8px 12px;gap:8px}
.toggle-desktop{display:none}
.search-divider{display:none}
.gear-desktop{display:none}
.mobile-settings-btn{display:flex}
.mobile-settings-panel{display:block}
.search-icon{margin-left:0}
.search-input-wrap{order:1}
.search-input-wrap input{font-size:15px;padding:10px 8px}
.placeholder-desktop{display:none}
.placeholder-mobile{display:inline;font-size:14px}
/* Search results mobile */
.search-results{max-height:260px;border-radius:0 0 12px 12px}
.search-result-item{padding:12px 16px}
.result-name{font-size:13px}
.result-meta{flex-direction:column;align-items:flex-end;gap:4px}
.result-code{font-size:9px}
.result-type{font-size:8px}
.pills{gap:6px}
.pill{font-size:11px;padding:4px 10px}
.scroll-hint{bottom:20px}
/* Directory */
.directory{padding:48px 24px}
.directory-inner{grid-template-columns:1fr;gap:32px}
.directory-left{text-align:center}
.section-title{font-size:clamp(32px,7vw,42px)}
.door{padding:28px 24px 24px;min-height:auto}
.door-title{font-size:clamp(24px,5vw,32px)}
.door-pills{gap:6px}
.door-pill{font-size:14px;padding:8px 12px}
.door-meta{font-size:10px}
/* Clasificadores */
.clasificadores{padding:32px 20px;height:auto;min-height:100vh;overflow-y:auto}
.clas-container{max-height:none}
.clas-header-row{flex-direction:column;align-items:flex-start;gap:12px}
.clas-ver-todos{align-self:flex-start}
.clas-main-grid{grid-template-columns:1fr;gap:12px}
.widgets-row{grid-template-columns:1fr}
.widget-h{flex-direction:column;align-items:flex-start;gap:12px}
.widget-right{width:100%;justify-content:space-between}
.widget-num-big{font-size:22px}
.clas-group-icon,.clas-subgroup-icon{width:28px;height:28px}
/* Historias */
.historias{padding:60px 24px}
.hist-header{flex-direction:column;align-items:flex-start;gap:16px}
.hist-grid{grid-template-columns:1fr 1fr;grid-auto-rows:auto;gap:12px}
.hist-tall{grid-row:span 1}
.hist-card{padding:20px 18px 18px}
.hist-title{font-size:16px}
.hist-tall .hist-title{font-size:18px}
.hist-desc{font-size:13px}
/* Visualizaciones */
.visualizaciones{padding:60px 24px}
.vis-header{flex-direction:column;align-items:flex-start;gap:16px}
.vis-grid-simple{grid-template-columns:1fr 1fr;gap:12px}
.vis-card{padding:20px 18px 18px}
.vis-title{font-size:16px}
/* Manifiesto */
.manifiesto{padding:60px 24px}
.manifiesto-title{font-size:clamp(28px,6vw,40px)}
/* Descargas */
.descargas{padding:60px 24px}
.descargas-title{font-size:clamp(28px,6vw,40px)}
.descargas-formats{gap:8px}
.format-pill{font-size:12px;padding:6px 12px}
/* Footer */
footer{padding:32px 24px}
.footer-inner{flex-direction:column;gap:12px;text-align:center}
.footer-org,.footer-right{font-size:10px}
}
/* ══════════════════════════════════════════════════════════════════════════
RESPONSIVE - MOBILE (480px)
══════════════════════════════════════════════════════════════════════════ */
@media(max-width:480px){
/* Section dots - bottom bar smaller */
.section-dots{bottom:16px;gap:8px;padding:10px 16px;border-radius:20px}
.section-dot{width:7px;height:7px}
/* Nav */
nav{padding:10px 16px}
.nav-logo{height:48px}
/* Hero */
.hero{padding:24px 16px}
.hero-title{font-size:clamp(32px,9vw,40px)}
.hero-sub{font-size:15px}
.hero-stats{font-size:10px;display:flex;flex-wrap:wrap;justify-content:center;gap:4px 0}
.stat-dot{margin:0 4px}
/* Search */
.search-wrap{padding:0}
.mobile-settings-btn{width:36px;height:36px}
.mobile-meta-item{padding:10px 12px;font-size:12px}
.search-results{max-height:220px}
.search-result-item{padding:10px 14px}
.result-name{font-size:12px}
.result-sigla{font-size:10px}
.pills-label{display:none}
.pill{font-size:10px;padding:4px 8px}
.scroll-hint{display:none}
/* Directory */
.directory{padding:40px 16px}
.directory-inner{gap:24px}
.section-tag{font-size:10px}
.section-title{font-size:clamp(28px,8vw,36px)}
.door{padding:24px 20px 20px;border-radius:18px}
.door-icon svg{width:22px;height:22px}
.door-title{font-size:clamp(22px,6vw,28px)}
.door-pill{font-size:13px;padding:7px 10px}
.door-footer{flex-direction:column;align-items:flex-start;gap:8px}
.door-meta{font-size:9px}
.door-cta{font-size:14px}
/* Clasificadores */
.clasificadores{padding:24px 16px}
.clas-header-section{margin-bottom:16px}
.section-title{font-size:clamp(24px,6vw,32px)}
.clas-subtitle{font-size:14px}
.clas-group-card{padding:12px;border-radius:14px;margin-bottom:10px}
.clas-subgroup{padding:10px;border-radius:10px}
.clas-group-header,.clas-subgroup-header{margin-bottom:8px}
.clas-group-label,.clas-subgroup-label{font-size:10px}
.clas-group-sub{font-size:12px}
.widget-card{padding:12px;border-radius:10px}
.widget-num-big{font-size:20px}
.widget-label{font-size:13px}
.widget-desc{font-size:12px}
.widget-tech{font-size:8px}
/* Historias */
.historias{padding:48px 16px}
.hist-subtitle{font-size:14px}
.hist-grid{grid-template-columns:1fr;gap:10px}
.hist-card{padding:18px 16px 16px;border-radius:14px}
.hist-tag{font-size:9px}
.hist-title{font-size:16px}
.hist-desc{font-size:12px;margin-top:8px}
.hist-cta{font-size:13px;margin-top:14px}
/* Visualizaciones */
.visualizaciones{padding:48px 16px}
.vis-subtitle{font-size:14px}
.vis-grid-simple{grid-template-columns:1fr;gap:10px}
.vis-card{padding:18px 16px 16px;border-radius:14px}
.vis-type{font-size:9px;padding:4px 8px}
.vis-title{font-size:16px}
.vis-desc{font-size:12px}
.vis-cta{font-size:13px;margin-top:14px}
/* Manifiesto */
.manifiesto{padding:48px 16px}
.section-tag-light{font-size:10px}
.manifiesto-title{font-size:clamp(24px,7vw,32px)}
/* Descargas */
.descargas{padding:48px 16px}
.descargas-title{font-size:clamp(24px,7vw,32px)}
.descargas-formats{flex-wrap:wrap;gap:6px}
.format-pill{font-size:11px;padding:5px 10px}
.descargas-link{font-size:14px;margin-top:24px}
/* Footer */
footer{padding:24px 16px}
.footer-org{font-size:9px;letter-spacing:0.08em}
.footer-right{font-size:9px}
}
</style>
\ No newline at end of file
<script>
import { onMount } from 'svelte';
// Estado base
let mounted = false;
let searchFocused = false;
let searchVal = '';
let searchInput;
let isLoading = false;
let results = null;
let error = null;
// Ordenamiento
let sortBy = 'amount';
let sortOrder = 'desc';
// Paginación
let currentPage = 1;
let isLoadingMore = false;
let allHits = [];
function toggleSort(field) {
if (sortBy === field) {
sortOrder = sortOrder === 'desc' ? 'asc' : 'desc';
} else {
sortBy = field;
sortOrder = 'desc';
}
}
$: sortedHits = allHits.length > 0 ? [...allHits].sort((a, b) => {
let valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
let valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
}) : [];
$: hasMoreResults = results && allHits.length < results.found;
// Modo de búsqueda
let searchMode = 'gastos'; // 'gastos' | 'ingresos'
let searchSubmode = 'programas'; // 'programas' | 'clasificadores'
let selectedClassifiers = [];
const CLASIFICADORES_GASTOS = [
{ id: 'finfun', label: 'Finalidad y función' },
{ id: 'objeto', label: 'Objetos de gasto' },
{ id: 'acteco', label: 'Actividad económica' },
{ id: 'entidad', label: 'Entidades' },
{ id: 'geografico', label: 'Geográfico', disabled: true },
];
const CLASIFICADORES_INGRESOS = [
{ id: 'rubro', label: 'Rubro', disabled: true },
{ id: 'organismo', label: 'Organismo', disabled: true },
{ id: 'fuente', label: 'Fuente', disabled: true },
{ id: 'entidad', label: 'Entidades' },
{ id: 'geografico',label: 'Geográfico', disabled: true },
];
$: activeClasificadores = searchMode === 'gastos' ? CLASIFICADORES_GASTOS : CLASIFICADORES_INGRESOS;
function toggleClassifier(id) {
if (selectedClassifiers.includes(id)) {
selectedClassifiers = selectedClassifiers.filter(c => c !== id);
} else {
selectedClassifiers = [...selectedClassifiers, id];
}
}
function setMode(mode) {
searchMode = mode;
selectedClassifiers = [];
if (mode === 'gastos') {
searchSubmode = 'programas';
} else {
searchSubmode = 'clasificadores';
}
}
function setSubmode(sub) {
searchSubmode = sub;
selectedClassifiers = [];
}
// Placeholder dinámico
$: placeholderText = getPlaceholderText(searchMode, searchSubmode, selectedClassifiers);
function getPlaceholderText(mode, submode, classifiers) {
if (mode === 'gastos') {
if (submode === 'programas') return 'Buscar en programas y proyectos...';
if (classifiers.length === 0) return 'Selecciona al menos un clasificador...';
if (classifiers.length === CLASIFICADORES_GASTOS.length) return 'Buscar en todos los clasificadores de gasto...';
return `Buscar en ${classifiers.length} clasificador${classifiers.length > 1 ? 'es' : ''} de gasto...`;
} else {
if (classifiers.length === 0) return 'Selecciona al menos un clasificador de ingreso...';
return `Buscar en ${classifiers.length} clasificador${classifiers.length > 1 ? 'es' : ''} de ingreso...`;
}
}
// Debounce
let debounceTimer;
let hintTimer;
let showMinLengthHint = false;
const DEBOUNCE_MS = 300;
const HINT_DELAY_MS = 1500;
const POBLACION = 12000000;
const API_BASE = '/api/search';
// Hint diario
let dailyData = null;
let dailyLoading = true;
async function fetchDaily() {
try {
const res = await fetch('/api/daily-summary');
if (res.ok) dailyData = await res.json();
} catch {
// silencioso — es un hint, no crítico
} finally {
dailyLoading = false;
}
}
// Ejemplos
const EXAMPLES = [
{ label: 'Vía férrea Bulo Bulo', color: 'rgba(201,167,81,0.12)', textColor: '#d4b962' },
{ label: 'Viru Viru', color: 'rgba(150,155,175,0.10)', textColor: '#a0a5b8' },
{ label: 'Cancha de fútbol', color: 'rgba(180,130,100,0.10)', textColor: '#b8947a' },
];
// Navegación
const SECTIONS_NAV = [
{ id: 'hero', label: 'Inicio' },
{ id: 'datos', label: 'Datos' },
{ id: 'clasificadores', label: 'Clasificadores' },
{ id: 'historias', label: 'Historias' },
{ id: 'visualizaciones',label: 'Visualizaciones' },
];
let currentSection = 'hero';
function scrollToSection(id) {
document.querySelector(`[data-section="${id}"]`)?.scrollIntoView({ behavior: 'smooth' });
}
onMount(() => {
setTimeout(() => { mounted = true; }, 80);
fetchDaily();
const isMobile = window.innerWidth < 768;
if (!isMobile) setTimeout(() => searchInput?.focus(), 300);
const sections = document.querySelectorAll('[data-section]');
const observer = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) currentSection = e.target.dataset.section; });
}, { threshold: 0.5 });
sections.forEach(s => observer.observe(s));
return () => observer.disconnect();
});
function searchExample(label) {
searchVal = label;
performSearch(label);
searchInput?.focus();
}
async function handleInput(e) {
searchVal = e.target.value;
clearTimeout(debounceTimer);
clearTimeout(hintTimer);
showMinLengthHint = false;
if (searchVal.length < 3) {
results = null;
error = null;
if (searchVal.length > 0) {
hintTimer = setTimeout(() => { showMinLengthHint = true; }, HINT_DELAY_MS);
}
return;
}
const needsClassifiers = searchMode === 'ingresos' ||
(searchMode === 'gastos' && searchSubmode === 'clasificadores');
if (needsClassifiers && selectedClassifiers.length === 0) {
results = null;
error = null;
return;
}
debounceTimer = setTimeout(() => performSearch(searchVal), DEBOUNCE_MS);
}
async function performSearch(query, page = 1) {
if (page === 1) {
isLoading = true;
allHits = [];
sortBy = 'amount';
sortOrder = 'desc';
} else {
isLoadingMore = true;
}
error = null;
currentPage = page;
try {
const isClassSearch = searchMode === 'ingresos' ||
(searchMode === 'gastos' && searchSubmode === 'clasificadores');
const params = new URLSearchParams({
q: query,
is_class: isClassSearch ? 'true' : 'false',
mode: searchMode,
page: page.toString()
});
if (isClassSearch && selectedClassifiers.length > 0) {
params.set('class_', selectedClassifiers.join(','));
}
const res = await fetch(`${API_BASE}?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
results = data;
allHits = page === 1 ? data.hits : [...allHits, ...data.hits];
} catch (err) {
console.error(err);
error = 'Error al buscar. Intenta de nuevo.';
if (page === 1) { results = null; allHits = []; }
}
isLoading = false;
isLoadingMore = false;
}
function loadMore() {
if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
}
function handleKeydown(e) {
if (e.key === 'Escape') { clearSearch(); searchInput?.blur(); }
}
function clearSearch() {
searchVal = '';
results = null;
allHits = [];
currentPage = 1;
error = null;
showMinLengthHint = false;
clearTimeout(debounceTimer);
clearTimeout(hintTimer);
searchInput?.focus();
}
// Helpers
function extractAfterHyphen(text) {
if (!text) return '';
const parts = text.split(' - ');
return parts.length > 1 ? parts.slice(1).join(' - ').trim() : text;
}
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
function isClassifierSearch(data) { return data?.hits?.[0]?.document?.is_class === true; }
function isEntidadSearch(data) { return data?.hits?.[0]?.document?.class_ === 'entidad'; }
function getStats(data) {
if (!data?.hits?.length) return null;
const hits = data.hits;
const isClassifier = isClassifierSearch(data);
const montoTotal = hits.reduce((s, h) => s + (h.document.devengado || 0), 0);
if (isClassifier) {
const isEntidad = isEntidadSearch(data);
const entidadesSet = new Set();
const subareasCount = {};
hits.forEach(h => {
const m = parseMetadatos(h.document.metadatos);
if (m.entidad) entidadesSet.add(m.entidad);
if (isEntidad) {
const sub = extractAfterHyphen(m.entidad_desc_subarea);
if (sub) subareasCount[sub] = (subareasCount[sub] || 0) + 1;
}
});
return { found: data.found, montoTotal, perCapita: montoTotal / POBLACION,
numEntidades: entidadesSet.size, subareasCount: isEntidad ? subareasCount : null,
isClassifier: true, classType: hits[0]?.document?.class_ || null };
} else {
const entidadesSet = new Set();
hits.forEach(h => { const m = parseMetadatos(h.document.metadatos); if (m.entidad) entidadesSet.add(m.entidad); });
const years = hits.map(h => h.document.gestion).filter(Boolean);
const minYear = Math.min(...years);
const maxYear = Math.max(...years);
return { found: data.found, montoTotal, perCapita: montoTotal / POBLACION,
numEntidades: entidadesSet.size,
yearRange: minYear === maxYear ? `${minYear}` : `${minYear} – ${maxYear}`,
isClassifier: false };
}
}
function formatMonto(num) {
if (num >= 1e9) return `${(num/1e9).toFixed(1)} mil millones de Bs`;
if (num >= 1e6) return `${(num/1e6).toFixed(1)} millones de Bs`;
if (num >= 1e3) return `${(num/1e3).toFixed(0)} mil Bs`;
return `${num.toFixed(0)} Bs`;
}
function formatMontoFull(num) {
return `${num.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} Bs`;
}
function generateCodigo(hit) {
const m = parseMetadatos(hit.document.metadatos);
return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`;
}
$: stats = results ? getStats(results) : null;
$: {
const _mode = searchMode;
const _submode = searchSubmode;
const _classifiers = selectedClassifiers;
if (searchVal.length >= 3 && mounted) {
clearTimeout(debounceTimer);
const needsClassifiers = _mode === 'ingresos' || (_mode === 'gastos' && _submode === 'clasificadores');
if (!needsClassifiers || _classifiers.length > 0) {
debounceTimer = setTimeout(() => performSearch(searchVal), 150);
} else {
results = null;
}
}
}
// ¿Mostrar chips de clasificadores?
$: showClassifierChips = searchMode === 'ingresos' ||
(searchMode === 'gastos' && searchSubmode === 'clasificadores');
// ¿Hay búsqueda activa?
$: isSearching = searchVal.length >= 3;
</script>
<svelte:head>
<title>Buscador | Presupuesto Abierto</title>
</svelte:head>
<div class="page" class:mounted>
<!-- Dots de navegación -->
<nav class="section-dots">
{#each SECTIONS_NAV as s}
<button
class="section-dot"
class:section-dot-active={currentSection === s.id}
on:click={() => scrollToSection(s.id)}
aria-label={s.label}
>
<span class="section-dot-label">{s.label}</span>
</button>
{/each}
</nav>
<!-- Hero -->
<main data-section="hero">
<div class="nav-logo">
<img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
</div>
<button class="nav-menu-btn" aria-label="Menú">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="search-container anim" style="--d:200ms">
<!-- Título -->
<h1 class="hero-title" class:hero-collapsed={allHits.length > 0}>
Presupuesto <span class="gold">Abierto</span>
</h1>
<p class="hero-sub" class:hero-collapsed={allHits.length > 0}>
<a href="/vista-diaria" class="hero-sub-link">Información diaria</a> e histórica de todo el Estado Boliviano.
</p>
<!-- Buscador -->
<div class="search-wrap">
<div class="search-bar" class:search-focused={searchFocused} class:has-results={isSearching}>
<svg class="search-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke={searchFocused ? '#D4B254' : '#8C8880'} stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<div class="search-input-wrap">
<input
type="text"
bind:this={searchInput}
bind:value={searchVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => searchFocused = true}
on:blur={() => searchFocused = false}
placeholder=""
/>
{#if !searchVal}
<div class="search-placeholder">
<span class="blink-cursor" class:cursor-hidden={searchFocused}></span>
<span class="placeholder-text">{placeholderText}</span>
</div>
{/if}
{#if searchVal && !isLoading}
<button class="search-clear" on:click={clearSearch}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{/if}
{#if isLoading}
<div class="search-loading"><span class="loading-dot"></span></div>
{/if}
</div>
</div>
<!-- ─── FILTROS INLINE ─── -->
<div class="filters-row" class:filters-hidden={isSearching}>
<!-- Fila 1: Gastos / Ingresos -->
<div class="filter-group">
<button
class="filter-chip filter-chip-mode"
class:filter-chip-active={searchMode === 'gastos'}
on:click={() => setMode('gastos')}
>
Gastos
</button>
<button
class="filter-chip filter-chip-mode"
class:filter-chip-active={searchMode === 'ingresos'}
on:click={() => setMode('ingresos')}
>
Ingresos
</button>
<span class="filter-divider"></span>
<!-- Sub-modo (solo cuando Gastos está activo) -->
{#if searchMode === 'gastos'}
<button
class="filter-chip"
class:filter-chip-active={searchSubmode === 'programas'}
on:click={() => setSubmode('programas')}
>
Programas y proyectos
</button>
<button
class="filter-chip"
class:filter-chip-active={searchSubmode === 'clasificadores'}
on:click={() => setSubmode('clasificadores')}
>
Clasificadores
</button>
{/if}
</div>
<!-- Fila 2: Clasificadores (cuando aplica) -->
{#if showClassifierChips}
<div class="filter-group filter-group-classifiers">
{#each activeClasificadores as clf}
<button
class="filter-chip filter-chip-classifier"
class:filter-chip-active={selectedClassifiers.includes(clf.id)}
class:filter-chip-disabled={clf.disabled}
on:click={() => { if (!clf.disabled) toggleClassifier(clf.id); }}
disabled={clf.disabled}
>
{clf.label}
{#if clf.disabled}
<span class="chip-soon">próx.</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- ─── ÁREA DE RESULTADOS / EJEMPLOS / HINT ─── -->
<div class="results-area">
<!-- Ejemplos -->
<div class="examples" class:examples-hidden={isSearching}>
<span class="examples-label">Prueba con:</span>
{#each EXAMPLES as ex}
<button
class="example-chip"
style="background:{ex.color}; color:{ex.textColor}"
on:click={() => searchExample(ex.label)}
>
{ex.label}
</button>
{/each}
</div>
<!-- Hint diario -->
{#if !isSearching && !dailyLoading}
<a
href="/vista-diaria"
class="daily-hint"
class:daily-hint-visible={!isSearching}
>
<span class="daily-dot"></span>
<span class="daily-text">
{#if dailyData}
Ayer el Estado ejecutó <strong>{formatMonto(dailyData.monto)}</strong>
{:else}
Ver el resumen de ejecución de ayer
{/if}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
{/if}
<!-- Estados de búsqueda -->
{#if showMinLengthHint && searchVal.length > 0 && searchVal.length < 3}
<div class="results-content">
<div class="results-hint">Escribe al menos 3 letras para buscar</div>
</div>
{:else if error && !results}
<div class="results-content">
<div class="results-error">{error}</div>
</div>
{:else if isLoading && !results}
<div class="results-content">
<div class="results-loading">Buscando...</div>
</div>
{:else if allHits.length > 0}
<div class="results-content results-content-filled">
{#if stats}
<div class="results-summary">
<div class="summary-row">
<div class="summary-left">
<div class="summary-header">
<span class="summary-count">{sortedHits.length}</span>
<span class="summary-header-text">de {stats.found.toLocaleString()} resultados</span>
{#if isLoading}<span class="updating-dot"></span>{/if}
</div>
<div class="summary-meta">
<span class="summary-tag summary-tag-gold">
{formatMonto(stats.montoTotal)} <span class="summary-period">· 2006–2025</span>
</span>
{#if stats.isClassifier}
{#if stats.subareasCount}
<span class="summary-tag summary-tag-entities">{stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
<span class="summary-arrow">→</span>
{#each Object.entries(stats.subareasCount).sort((a,b) => b[1]-a[1]) as [sub, count]}
<span class="summary-tag summary-tag-subarea">{sub}: {count}</span>
{/each}
{:else}
<span class="summary-tag">{stats.found} resultados en {stats.classType}</span>
{/if}
{:else}
<span class="summary-tag">{stats.numEntidades} {stats.numEntidades === 1 ? 'institución' : 'instituciones'}</span>
{#if stats.yearRange}
<span class="summary-tag">{stats.yearRange}</span>
{/if}
{/if}
</div>
</div>
<div class="sort-buttons">
{#if !stats.isClassifier}
<button class="sort-btn" class:sort-btn-active={sortBy === 'year'} on:click={() => toggleSort('year')}>
Año
{#if sortBy === 'year'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
{:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
</svg>
{/if}
</button>
{/if}
<button class="sort-btn" class:sort-btn-active={sortBy === 'amount'} on:click={() => toggleSort('amount')}>
Monto
{#if sortBy === 'amount'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
{:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
</svg>
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class="results-list">
{#each sortedHits as hit, i}
{@const meta = parseMetadatos(hit.document.metadatos)}
{@const isEntidadClass = hit.document.class_ === 'entidad'}
{@const isDA = isEntidadClass && meta.da && meta.entidad_desc_entidad}
{@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
{@const isClassResult = hit.document.is_class === true}
<a
href={isEntidadClass
? `/entidad/${meta.entidad}`
: (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
class="result-card"
style="--delay: {i * 30}ms"
>
<div class="result-content">
<div class="result-text">
{#if hit.highlights?.[0]?.snippet}
{@html hit.highlights[0].snippet}
{:else}
{hit.document.texto}
{/if}
</div>
<div class="result-meta">
{#if isEntidadClass}
{#if isDA}<span class="result-da-badge">DA</span><span class="result-parent">de {parentEntity}</span>{/if}
{#if subarea}<span class="result-subarea">{subarea}</span>{/if}
{:else if isClassResult}
<span class="result-class-type">{hit.document.class_}</span>
{:else}
<span class="result-year">{hit.document.gestion}</span>
{/if}
<span class="result-monto">{formatMontoFull(hit.document.devengado)}</span>
</div>
</div>
<svg class="result-link-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
{/each}
</div>
{#if hasMoreResults}
<div class="load-more-container">
<button class="load-more-btn" on:click={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore}
<span class="loading-dot"></span> Cargando...
{:else}
Cargar más ({allHits.length} de {results.found})
{/if}
</button>
</div>
{/if}
</div>
{:else if results && results.hits?.length === 0}
<div class="results-content">
<div class="results-empty">Sin resultados para "{searchVal}"</div>
</div>
{/if}
<div class="area-spacer" class:area-spacer-hidden={allHits.length > 0}></div>
</div>
</div>
</div>
<!-- Scroll hint -->
<div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching}>
<svg class="scroll-icon-mouse" width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
<rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
</svg>
<svg class="scroll-icon-touch" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
<span>Explora la página</span>
</div>
</main>
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&display=swap');
:global(*) { margin: 0; padding: 0; box-sizing: border-box; }
:global(html), :global(body) { overflow-x: hidden; }
.page {
--serif: 'Qanelas', system-ui, sans-serif;
--sans: 'Qanelas', system-ui, sans-serif;
--mono: 'JetBrains Mono', monospace;
--gold: #D4B254;
--gold-bright: #EAD06A;
--gold-soft: rgba(212,178,84,0.15);
--green: #4A8B6E;
--bg: #1A1A18;
--bg-elevated: #232321;
--bg-surface: #2A2A28;
--text: #F7F3EB;
--text-secondary: #E0DCD4;
--text-muted: #B5B1A9;
--text-dim: #8C8880;
font-family: var(--sans);
background: var(--bg);
color: var(--text);
height: 100vh;
max-height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
@keyframes pulse { 0%,100% { opacity:0.4; } 50% { opacity:1; } }
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
/* ── Dots ── */
.section-dots {
position: fixed; right: 24px; top: 50%; transform: translateY(-50%);
z-index: 100; display: flex; flex-direction: column; gap: 12px;
background: rgba(255,255,255,0.06); backdrop-filter: blur(8px);
padding: 12px 8px; border-radius: 20px;
}
.section-dot {
width:10px; height:10px; border-radius:50%;
border:1.5px solid rgba(180,177,169,0.4);
background:rgba(180,177,169,0.15);
cursor:pointer; transition:all 0.3s; padding:0; position:relative;
}
.section-dot:hover { border-color:rgba(180,177,169,0.6); background:rgba(180,177,169,0.3); transform:scale(1.2); }
.section-dot-active { background:var(--gold); border-color:var(--gold); }
.section-dot-label {
position:absolute; right:20px; top:50%; transform:translateY(-50%);
font-family:var(--mono); font-size:10px; letter-spacing:0.05em;
color:var(--text-dim); white-space:nowrap; opacity:0; transition:opacity 0.2s; pointer-events:none;
}
.section-dot:hover .section-dot-label { opacity:1; }
/* ── Anim ── */
.anim { opacity:0; transform:translateY(18px); transition:all 0.8s cubic-bezier(0.16,1,0.3,1); transition-delay:var(--d,0ms); }
.mounted .anim { opacity:1; transform:translateY(0); }
/* ── Nav ── */
.nav-logo { position:absolute; top:24px; left:32px; z-index:50; opacity:0.75; pointer-events:none; }
.nav-logo img { height:100px; width:auto; object-fit:contain; }
.nav-menu-btn {
position:absolute; top:24px; right:24px; z-index:50;
background:none; border:none; cursor:pointer; color:var(--text-dim);
opacity:0.6; padding:8px; border-radius:8px; transition:all 0.2s;
}
.nav-menu-btn:hover { opacity:1; color:var(--text); background:rgba(255,255,255,0.05); }
/* ── Main ── */
main {
flex:1; display:flex; flex-direction:column; align-items:center;
justify-content:flex-start; padding:28vh 48px 0;
position:relative; overflow:hidden;
}
.search-container { width:100%; max-width:820px; text-align:center; position:relative; }
/* ── Título ── */
.hero-title {
font-size:clamp(40px,5.5vw,64px); font-weight:900;
line-height:1.05; letter-spacing:-0.035em; color:var(--text);
margin-top:40px; transition:all 0.35s cubic-bezier(0.16,1,0.3,1);
}
.hero-title.hero-collapsed { opacity:0; height:0; overflow:hidden; margin:0; }
.hero-sub {
font-size:clamp(16px,1.6vw,18px); color:#A5A29A;
margin-top:10px; margin-bottom:16px; line-height:1.6;
transition:all 0.35s cubic-bezier(0.16,1,0.3,1);
}
.hero-sub.hero-collapsed { opacity:0; height:0; overflow:hidden; margin:0; }
.hero-sub-link { color:inherit; text-decoration:none; border-bottom:1px dotted rgba(165,162,154,0.5); transition:all 0.2s; padding-bottom:1px; }
.hero-sub-link:hover { color:#4A8B6E; border-bottom-color:#4A8B6E; }
.gold { color:var(--gold); }
/* ── Search bar ── */
.search-wrap { position:relative; border-radius:16px; transition:all 0.35s cubic-bezier(0.16,1,0.3,1); z-index:20; }
.search-bar {
display:flex; align-items:center; background:var(--bg-elevated);
border-radius:16px; padding:8px 20px;
border:1px solid transparent; transition:all 0.3s cubic-bezier(0.16,1,0.3,1);
}
.search-focused { background:var(--bg-surface); border:1px solid rgba(255,255,255,0.08); box-shadow:0 12px 40px rgba(0,0,0,0.25); }
.search-bar.has-results { border-radius:16px 16px 0 0; background:var(--bg-surface); border:1px solid rgba(255,255,255,0.08); border-bottom:none; box-shadow:0 12px 40px rgba(0,0,0,0.25); }
.search-icon { flex-shrink:0; transition:stroke 0.3s; }
.search-input-wrap { flex:1; position:relative; display:flex; align-items:center; }
.search-input-wrap input {
width:100%; background:transparent; border:none; outline:none;
color:var(--text); font-size:17px; font-weight:500; font-family:var(--sans);
padding:14px 16px 14px 10px; caret-color:var(--gold); letter-spacing:-0.01em;
}
.search-placeholder { position:absolute; left:10px; top:50%; transform:translateY(-50%); display:flex; align-items:center; pointer-events:none; }
.blink-cursor { width:2px; height:20px; background:var(--gold); margin-right:4px; animation:blink 1s step-end infinite; border-radius:1px; flex-shrink:0; }
.blink-cursor.cursor-hidden { opacity:0; animation:none; }
.placeholder-text { font-family:var(--sans); font-size:16px; color:#B8B5AD; }
.search-clear { background:rgba(255,255,255,0.08); border:none; border-radius:8px; width:32px; height:32px; display:flex; align-items:center; justify-content:center; cursor:pointer; color:var(--text-muted); transition:all 0.2s; }
.search-clear:hover { background:rgba(255,255,255,0.15); color:var(--text); }
.search-loading { display:flex; align-items:center; padding-right:8px; }
.loading-dot { width:8px; height:8px; border-radius:50%; background:var(--gold); animation:pulse 1s ease-in-out infinite; }
/* ─────────────────────────────────────────
FILTROS INLINE — el cambio principal
───────────────────────────────────────── */
.filters-row {
display:flex; flex-direction:column; gap:8px;
padding:10px 2px 0;
transition:opacity 0.25s ease, max-height 0.35s cubic-bezier(0.16,1,0.3,1);
max-height:120px; overflow:hidden;
}
.filters-hidden { opacity:0; max-height:0; pointer-events:none; }
.filter-group {
display:flex; align-items:center; gap:6px; flex-wrap:wrap;
justify-content:center;
}
.filter-group-classifiers { padding-left:0; }
.filter-divider {
width:1px; height:14px; background:rgba(255,255,255,0.12);
margin:0 4px; flex-shrink:0;
}
.filter-chip {
font-family:var(--sans); font-size:12px; font-weight:500;
padding:5px 12px; border-radius:20px; border:1px solid rgba(255,255,255,0.1);
background:rgba(255,255,255,0.04); color:var(--text-muted);
cursor:pointer; transition:all 0.18s; white-space:nowrap;
display:flex; align-items:center; gap:5px;
}
.filter-chip:hover:not(:disabled) {
background:rgba(255,255,255,0.08); color:var(--text);
border-color:rgba(255,255,255,0.18);
}
.filter-chip-active {
background:rgba(212,178,84,0.12); color:var(--gold);
border-color:rgba(212,178,84,0.35);
}
.filter-chip-active:hover { background:rgba(212,178,84,0.18); }
.filter-chip-mode { font-weight:600; font-size:12.5px; }
.filter-chip-disabled {
opacity:0.4; cursor:default; pointer-events:none;
}
.chip-soon {
font-family:var(--mono); font-size:9px; letter-spacing:0.04em;
color:var(--text-dim); background:rgba(255,255,255,0.06);
padding:1px 5px; border-radius:4px;
}
/* ─────────────────────────────────────────
HINT DIARIO
───────────────────────────────────────── */
.daily-hint {
display:inline-flex; align-items:center; gap:8px;
padding:8px 16px; border-radius:20px;
border:1px solid rgba(74,139,110,0.25);
background:rgba(74,139,110,0.07);
color:var(--text-muted); text-decoration:none;
font-size:13px; transition:all 0.2s;
opacity:0; pointer-events:none;
transform:translateY(4px);
}
.daily-hint-visible { opacity:1; pointer-events:auto; transform:translateY(0); transition:opacity 0.4s ease 0.1s, transform 0.4s ease 0.1s, background 0.2s, border-color 0.2s; }
.daily-hint:hover { background:rgba(74,139,110,0.12); border-color:rgba(74,139,110,0.4); color:var(--text); }
.daily-hint strong { color:#6BBF97; font-weight:600; }
.daily-dot {
width:7px; height:7px; border-radius:50%;
background:#4A8B6E; flex-shrink:0;
box-shadow:0 0 0 3px rgba(74,139,110,0.2);
animation:pulse 2s ease-in-out infinite;
}
/* ── Results area ── */
.results-area { margin-top:16px; min-height:240px; position:relative; }
.area-spacer { position:absolute; bottom:0; left:50%; transform:translateX(-50%); height:80px; pointer-events:none; transition:opacity 0.4s; }
.area-spacer-hidden { opacity:0; }
/* ── Examples ── */
.examples {
display:flex; align-items:center; justify-content:center;
gap:10px; flex-wrap:wrap; padding:12px 0 16px;
transition:opacity 0.25s ease;
}
.examples-label { font-size:13px; color:var(--text-dim); }
.example-chip { font-family:var(--sans); font-size:13px; padding:8px 14px; border-radius:20px; border:none; cursor:pointer; transition:all 0.2s; font-weight:500; }
.example-chip:hover { transform:translateY(-1px); filter:brightness(1.1); }
.examples-hidden { opacity:0; pointer-events:none; }
/* ── Results ── */
.results-content { position:absolute; top:-16px; left:0; right:0; z-index:10; text-align:left; }
.results-content-filled {
background:var(--bg-surface); border-radius:0 0 16px 16px;
border:1px solid rgba(255,255,255,0.08); border-top:none;
box-shadow:0 12px 40px rgba(0,0,0,0.25); max-height:450px; overflow-y:auto;
}
.results-loading, .results-error, .results-empty, .results-hint {
padding:16px 24px; text-align:center; color:var(--text-muted); font-size:14px; line-height:1.5;
}
.results-error { color:#F28B82; }
.results-empty { color:var(--text-dim); }
/* Summary */
.results-summary { padding:12px 16px; border-bottom:1px solid rgba(255,255,255,0.06); }
.summary-row { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.summary-left { flex:1; min-width:0; }
.summary-header { display:flex; align-items:baseline; gap:6px; margin-bottom:4px; flex-wrap:wrap; }
.summary-count { font-weight:700; color:var(--text); font-size:20px; font-family:var(--mono); letter-spacing:-0.02em; }
.summary-header-text { color:var(--text-secondary); font-size:15px; }
.updating-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--gold); animation:pulse 0.8s ease-in-out infinite; margin-left:6px; }
.summary-meta { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.summary-tag { font-family:var(--mono); font-size:12px; color:var(--text-dim); background:rgba(255,255,255,0.05); padding:3px 8px; border-radius:4px; }
.summary-tag-gold { color:var(--gold); background:rgba(212,178,84,0.1); }
.summary-tag-entities { color:#7EB8D8; background:rgba(126,184,216,0.1); }
.summary-tag-subarea { color:#A8C4A0; background:rgba(168,196,160,0.1); }
.summary-period { color:var(--text-dim); font-weight:300; }
.summary-arrow { color:var(--text-dim); font-size:12px; }
/* Sort */
.sort-buttons { display:flex; gap:6px; flex-shrink:0; align-items:flex-start; padding-top:2px; }
.sort-btn { display:flex; align-items:center; gap:4px; padding:5px 10px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.08); border-radius:6px; font-family:var(--mono); font-size:11px; color:var(--text-muted); cursor:pointer; transition:all 0.15s; }
.sort-btn:hover { background:rgba(255,255,255,0.09); color:var(--text); }
.sort-btn-active { background:rgba(212,178,84,0.1); border-color:rgba(212,178,84,0.3); color:var(--gold); }
/* Results list */
.results-list { padding:4px 0; }
.result-card { display:flex; align-items:center; gap:10px; padding:10px 16px; text-decoration:none; color:inherit; border-left:3px solid transparent; transition:all 0.2s ease; animation:fadeSlideIn 0.25s ease both; animation-delay:var(--delay,0ms); }
@keyframes fadeSlideIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
.result-card:hover { background:rgba(255,255,255,0.04); border-left-color:var(--gold); transform:translateX(4px); }
.result-link-icon { flex-shrink:0; opacity:0; color:var(--gold); transition:opacity 0.2s; }
.result-card:hover .result-link-icon { opacity:1; }
.result-content { flex:1; min-width:0; }
.result-text { font-size:14px; color:var(--text); line-height:1.4; margin-bottom:4px; }
.result-text :global(mark) { background:#F5E047; color:#1A1A18; padding:1px 4px; margin:0 1px; border-radius:3px; font-weight:600; box-decoration-break:clone; -webkit-box-decoration-break:clone; }
.result-meta { display:flex; flex-wrap:wrap; gap:10px; font-family:var(--mono); font-size:12px; }
.result-year { color:var(--text-dim); }
.result-monto { color:var(--gold); font-weight:500; }
.result-da-badge { background:rgba(212,133,106,0.2); color:#D4856A; padding:2px 6px; border-radius:4px; font-size:10px; font-weight:600; letter-spacing:0.05em; }
.result-parent { color:var(--text-muted); font-size:11px; }
.result-subarea { color:#7EB8D8; font-size:11px; }
.result-class-type { color:var(--text-dim); font-size:10px; text-transform:uppercase; letter-spacing:0.05em; }
/* Load more */
.load-more-container { padding:8px 16px 16px; border-top:1px solid rgba(255,255,255,0.04); margin-top:4px; }
.load-more-btn { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; padding:12px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:8px; font-family:var(--mono); font-size:13px; color:var(--text-muted); cursor:pointer; transition:all 0.2s; }
.load-more-btn:hover:not(:disabled) { background:rgba(255,255,255,0.08); border-color:rgba(255,255,255,0.12); color:var(--text); }
.load-more-btn:disabled { cursor:wait; opacity:0.7; }
.load-more-btn .loading-dot { width:6px; height:6px; }
/* Scroll hint */
.scroll-hint { position:absolute; bottom:28px; left:0; right:0; margin:0 auto; width:fit-content; display:flex; flex-direction:column; align-items:center; gap:10px; color:var(--text-dim); animation:scrollHint 2.5s ease-in-out infinite; z-index:1; transition:opacity 0.3s; }
.scroll-hint-hidden { opacity:0; pointer-events:none; }
.scroll-hint span { font-family:var(--mono); font-size:10px; letter-spacing:0.2em; text-transform:uppercase; opacity:0.7; }
.scroll-icon-mouse { display:block; }
.scroll-icon-touch { display:none; }
@keyframes scrollHint { 0%,100% { transform:translateY(0); opacity:0.6; } 50% { transform:translateY(5px); opacity:1; } }
/* ── Mobile ── */
@media (max-width: 768px) {
.page { height:100vh; height:100dvh; }
.section-dots { display:none; }
.nav-logo { top:16px; left:20px; opacity:1; }
.nav-logo img { height:60px; }
.nav-menu-btn { top:16px; right:16px; }
main { padding:0 20px; justify-content:center; min-height:100%; }
.search-container { padding-top:80px; }
.hero-title { margin-top:0; font-size:clamp(28px,8vw,40px); }
.hero-sub { font-size:14px; margin-bottom:12px; }
.search-bar { padding:6px 12px; }
.search-input-wrap input { font-size:14px; padding:10px 10px 10px 6px; }
.search-placeholder { left:6px; }
.placeholder-text { font-size:13px; }
.search-icon { width:18px; height:18px; }
.filters-row { gap:6px; padding-top:8px; }
.filter-chip { font-size:11px; padding:4px 10px; }
.filter-chip-mode { font-size:11.5px; }
.examples { gap:8px; }
.example-chip { font-size:12px; padding:6px 12px; }
.daily-hint { font-size:12px; padding:7px 12px; }
.results-area { min-height:200px; }
.results-content-filled { max-height:45vh; }
.result-card { padding:8px 14px; }
.result-card:hover { transform:none; }
.result-text { font-size:14px; line-height:1.45; }
.result-meta { font-size:12px; gap:10px; }
.summary-count { font-size:16px; }
.summary-meta { font-size:12px; }
.scroll-icon-mouse { display:none; }
.scroll-icon-touch { display:block; }
.scroll-hint { position:fixed; bottom:24px; }
}
@media (max-width: 400px) {
.nav-logo img { height:50px; }
main { padding:0 16px; }
.search-container { padding-top:70px; }
.search-bar { padding:5px 10px; }
.search-input-wrap input { font-size:13px; padding:8px 8px 8px 4px; }
.placeholder-text { font-size:12px; }
.hero-title { font-size:26px; }
.hero-sub { font-size:13px; margin-bottom:10px; }
.results-area { min-height:180px; }
.results-content-filled { max-height:40vh; }
.scroll-hint { display:none; }
.examples { flex-direction:column; gap:6px; }
.filter-group { gap:5px; }
.filter-divider { display:none; }
}
</style>
This diff could not be displayed because it is too large.
<script>
import { onMount } from 'svelte';
import * as d3 from 'd3';
import * as Plot from '@observablehq/plot';
// Estado reactivo
let codigoSeleccionado = $state('1901');
let gestionSeleccionada = $state(2025);
let resumenData = $state([]);
let distribucionesData = $state([]);
let codigosDisponibles = $state([]);
let cargando = $state(true);
// Datos derivados
let tipoCodigo = $derived.by(() => {
const row = resumenData.find(d => String(d.codigo) === codigoSeleccionado);
return row?.tipo_codigo || 'entidad';
});
let esEntidad = $derived(tipoCodigo === 'entidad');
let resumenFiltrado = $derived(
resumenData.filter(d => String(d.codigo) === codigoSeleccionado)
);
let historiaGastos = $derived(
resumenFiltrado
.filter(d => d.tipo === 'gastos')
.sort((a, b) => a.gestion - b.gestion)
);
let historiaIngresos = $derived(
resumenFiltrado
.filter(d => d.tipo === 'ingresos')
.sort((a, b) => a.gestion - b.gestion)
);
let gestiones = $derived(() => {
const years = [...new Set(resumenFiltrado.map(d => d.gestion))].sort();
return years;
});
let rankingGastos = $derived(() => {
const row = historiaGastos.find(d => d.gestion === gestionSeleccionada);
return row ? { posicion: row.ranking, total: '~600' } : null;
});
let rankingIngresos = $derived(() => {
const row = historiaIngresos.find(d => d.gestion === gestionSeleccionada);
return row ? { posicion: row.ranking, total: '~600' } : null;
});
// Distribuciones filtradas por codigo y gestion
let distFiltradas = $derived(
distribucionesData.filter(d =>
String(d.codigo) === codigoSeleccionado &&
d.gestion === gestionSeleccionada
)
);
// Clasificadores: cada hijo es un segmento de la barra
function prepararSegmentos(data) {
return data
.map(d => ({
codigo: d.hijo,
nombre: d.desc_hijo,
monto: d.devengado,
padre: d.desc_padre
}))
.filter(d => d.monto > 0)
.sort((a, b) => b.monto - a.monto);
}
let clasificadoresGasto = $derived.by(() => {
const gastos = distFiltradas.filter(d => d.tipo === 'gastos');
const dims = [
{ key: 'objeto', label: 'Objetos de gasto' },
{ key: 'finfun', label: 'Finalidad y función' },
{ key: 'acteco', label: 'Sectores económicos' }
];
return dims
.map(dim => ({
...dim,
data: prepararSegmentos(gastos.filter(d => d.dimension === dim.key))
}))
.filter(dim => dim.data.length > 0);
});
let clasificadoresIngreso = $derived.by(() => {
if (!esEntidad) return [];
const ingresos = distFiltradas.filter(d => d.tipo === 'ingresos');
const dims = [
{ key: 'rubro', label: 'Rubros de ingreso' },
{ key: 'organismo', label: 'Organismos financiadores' }
];
return dims
.map(dim => ({
...dim,
data: prepararSegmentos(ingresos.filter(d => d.dimension === dim.key))
}))
.filter(dim => dim.data.length > 0);
});
// Contenedores D3
let barContainers = $state({});
let hoverData = $state({});
let chartIngresosContainer = $state(null);
let chartGastosContainer = $state(null);
// Totales
function getTotal(data) {
return data.reduce((sum, d) => sum + d.monto, 0);
}
// Formatear montos
function formatearMonto(valor) {
if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)} mil millones`;
if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)} millones`;
if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)} mil`;
return valor.toFixed(0);
}
function formatearMontoCorto(valor) {
if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)}K`;
return valor.toFixed(0);
}
// Colores del tema
function getChartColors() {
const root = document.documentElement;
const isDark = root.classList.contains('dark');
return {
barDefault: isDark ? '#3a3a38' : '#e0e0e0',
barHighlight: isDark ? '#C9A751' : '#F3EAD8',
barStroke: isDark ? '#5a5a55' : '#b0b0b0',
barStrokeDefault: isDark ? '#2a2a28' : 'white'
};
}
// Renderizar barras D3
function renderizarBarras(key, data) {
const container = barContainers[key];
if (!container || data.length === 0) return;
const total = getTotal(data);
const height = 48;
const radius = 4;
d3.select(container).selectAll('*').remove();
const containerRect = container.getBoundingClientRect();
const width = containerRect.width || 800;
const svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('preserveAspectRatio', 'none')
.style('display', 'block');
let cumulative = 0;
const segments = data.map(d => {
const segWidth = (d.monto / total) * width;
const segment = { ...d, x: cumulative, width: segWidth };
cumulative += segWidth;
return segment;
});
const chartColors = getChartColors();
const colorDefault = chartColors.barDefault;
const colorHover = chartColors.barHighlight;
const colorFirst = chartColors.barHighlight;
const bars = svg.selectAll('rect')
.data(segments)
.enter()
.append('rect')
.attr('x', d => d.x)
.attr('y', 2)
.attr('width', d => Math.max(d.width - 1, 1))
.attr('height', height - 4)
.attr('rx', (d, i) => {
if (i === 0) return radius;
if (i === segments.length - 1) return radius;
return 0;
})
.attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
.style('cursor', 'pointer')
.on('mouseenter', function(event, d) {
bars
.attr('fill', colorDefault)
.attr('stroke', chartColors.barStrokeDefault)
.attr('stroke-width', 0.5);
d3.select(this)
.attr('fill', colorHover)
.attr('stroke', chartColors.barStroke)
.attr('stroke-width', 1);
hoverData[key] = d;
hoverData = { ...hoverData };
})
.on('mouseleave', function() {
bars
.attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
hoverData[key] = null;
hoverData = { ...hoverData };
});
}
// Item a mostrar (hover o primero)
function getDisplayItem(key, data) {
const hover = hoverData[key];
if (hover) return hover;
return data[0] || null;
}
// Renderizar gráficos de historia
function renderizarHistoria() {
if (esEntidad && chartIngresosContainer && historiaIngresos.length > 0) {
chartIngresosContainer.innerHTML = '';
const plot = Plot.plot({
width: chartIngresosContainer.clientWidth,
height: 180,
marginLeft: 50,
marginRight: 20,
marginTop: 20,
marginBottom: 30,
style: { background: 'transparent', fontFamily: 'var(--font-sans)' },
x: { label: null, tickFormat: d => d },
y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
marks: [
Plot.areaY(historiaIngresos, { x: 'gestion', y: 'devengado', fill: '#4A8B6E', fillOpacity: 0.3 }),
Plot.lineY(historiaIngresos, { x: 'gestion', y: 'devengado', stroke: '#4A8B6E', strokeWidth: 2 }),
Plot.dot(historiaIngresos, { x: 'gestion', y: 'devengado', fill: '#4A8B6E', r: 3 })
]
});
chartIngresosContainer.appendChild(plot);
}
if (chartGastosContainer && historiaGastos.length > 0) {
chartGastosContainer.innerHTML = '';
const plot = Plot.plot({
width: chartGastosContainer.clientWidth,
height: 180,
marginLeft: 50,
marginRight: 20,
marginTop: 20,
marginBottom: 30,
style: { background: 'transparent', fontFamily: 'var(--font-sans)' },
x: { label: null, tickFormat: d => d },
y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
marks: [
Plot.areaY(historiaGastos, { x: 'gestion', y: 'devengado', fill: '#C9A751', fillOpacity: 0.3 }),
Plot.lineY(historiaGastos, { x: 'gestion', y: 'devengado', stroke: '#C9A751', strokeWidth: 2 }),
Plot.dot(historiaGastos, { x: 'gestion', y: 'devengado', fill: '#C9A751', r: 3 })
]
});
chartGastosContainer.appendChild(plot);
}
}
// Renderizar todas las barras
function renderizarTodasLasBarras() {
setTimeout(() => {
clasificadoresGasto.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
clasificadoresIngreso.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
}, 100);
}
// Efectos
$effect(() => {
// Trigger on data changes
clasificadoresGasto;
clasificadoresIngreso;
renderizarTodasLasBarras();
});
$effect(() => {
if (chartGastosContainer) {
// Trigger on history data changes
historiaGastos;
historiaIngresos;
esEntidad;
setTimeout(renderizarHistoria, 150);
}
});
onMount(async () => {
// Cargar CSVs
const [resumenRaw, distRaw] = await Promise.all([
fetch('/resumen.csv').then(r => r.text()),
fetch('/distribuciones.csv').then(r => r.text())
]);
resumenData = d3.csvParse(resumenRaw, d => ({
tipo: d.tipo,
codigo: d.codigo,
gestion: +d.gestion,
devengado: +d.devengado,
ranking: +d.ranking,
tipo_codigo: d.tipo_codigo
}));
distribucionesData = d3.csvParse(distRaw, d => ({
tipo: d.tipo,
codigo: d.codigo,
dimension: d.dimension,
gestion: +d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: +d.devengado,
tipo_codigo: d.tipo_codigo
}));
// Extraer codigos únicos
codigosDisponibles = [...new Set(resumenData.map(d => d.codigo))].sort((a, b) => {
return parseFloat(a) - parseFloat(b);
});
cargando = false;
// Observer para tema
const observer = new MutationObserver(() => {
renderizarTodasLasBarras();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Resize observer para charts
const resizeObserver = new ResizeObserver(() => {
renderizarHistoria();
});
if (chartIngresosContainer) resizeObserver.observe(chartIngresosContainer);
if (chartGastosContainer) resizeObserver.observe(chartGastosContainer);
return () => {
observer.disconnect();
resizeObserver.disconnect();
};
});
</script>
<svelte:head>
<title>Radiografía - {codigoSeleccionado}</title>
</svelte:head>
{#if cargando}
<div class="dashboard">
<p>Cargando datos...</p>
</div>
{:else}
<div class="dashboard">
<!-- Header sticky -->
<header class="sticky-header">
<div class="sticky-row">
<div class="sticky-left">
<h1 class="entidad-nombre">Código: {codigoSeleccionado}</h1>
<div class="entidad-meta">
<span class="tipo-badge" class:da={!esEntidad}>{tipoCodigo}</span>
<span class="entidad-sector">{esEntidad ? 'Gastos e Ingresos' : 'Solo Gastos'}</span>
</div>
</div>
<div class="sticky-controls">
<select class="control-select" bind:value={codigoSeleccionado}>
{#each codigosDisponibles as cod}
<option value={cod}>{cod}</option>
{/each}
</select>
<select class="control-select" bind:value={gestionSeleccionada}>
{#each gestiones() as g}
<option value={g}>{g}</option>
{/each}
</select>
</div>
</div>
</header>
<!-- Ranking -->
<div class="ranking-bar">
{#if rankingGastos()}
<div class="ranking-item">
<span class="ranking-label">Ranking gastos</span>
<span class="ranking-value">#{rankingGastos().posicion}</span>
<span class="ranking-total">de {rankingGastos().total}</span>
</div>
{/if}
{#if esEntidad && rankingIngresos()}
<div class="ranking-sep"></div>
<div class="ranking-item">
<span class="ranking-label">Ranking ingresos</span>
<span class="ranking-value">#{rankingIngresos().posicion}</span>
<span class="ranking-total">de {rankingIngresos().total}</span>
</div>
{/if}
</div>
<!-- Historia temporal -->
<section class="seccion">
<h2 class="seccion-titulo">{historiaGastos.length > 0 ? `${historiaGastos[0].gestion} – ${historiaGastos[historiaGastos.length - 1].gestion}` : ''}</h2>
<div class="historia-grid" class:single={!esEntidad}>
{#if esEntidad}
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Ingresos</span>
{#if historiaIngresos.length > 0}
<span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].devengado)} Bs</span>
{/if}
</div>
<div class="historia-chart" bind:this={chartIngresosContainer}></div>
</div>
{/if}
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Gastos</span>
{#if historiaGastos.length > 0}
<span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].devengado)} Bs</span>
{/if}
</div>
<div class="historia-chart" bind:this={chartGastosContainer}></div>
</div>
</div>
</section>
<!-- Composición del gasto -->
{#if clasificadoresGasto.length > 0}
<section class="seccion">
<h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresGasto as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
<span class="clasificador-padre">{displayItem?.padre || ''}</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
</section>
{/if}
<!-- Origen del dinero (solo entidad) -->
{#if esEntidad && clasificadoresIngreso.length > 0}
<section class="seccion">
<h2 class="seccion-titulo">Fuentes de ingreso y organismos financiadores <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresIngreso as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
<span class="clasificador-padre">{displayItem?.padre || ''}</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
</section>
{/if}
</div>
{/if}
<style>
.dashboard {
min-height: 100vh;
background: var(--theme-fondo);
color: var(--theme-texto);
padding: 2rem;
padding-top: 2rem;
font-family: var(--font-sans);
}
/* Sticky header */
.sticky-header {
position: sticky;
top: 0;
z-index: 50;
padding: 0.75rem 2rem;
margin: 0 -2rem 1.5rem -2rem;
background: color-mix(in srgb, var(--theme-fondo) 90%, transparent);
border-bottom: 1px solid var(--theme-borde);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.sticky-row {
display: flex;
align-items: center;
gap: 1rem;
max-width: calc(100% - 220px); /* dejar espacio para el navbar fixed */
}
.sticky-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.entidad-nombre {
font-size: 1.1rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entidad-meta {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.entidad-sector {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.6;
}
.tipo-badge {
font-size: 0.6rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
background: rgba(74, 139, 110, 0.15);
color: #4A8B6E;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tipo-badge.da {
background: rgba(201, 167, 81, 0.15);
color: #C9A751;
}
.sticky-controls {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.control-select {
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
font-weight: 600;
background: var(--theme-tarjeta);
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: var(--theme-titulo);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
padding-right: 1.5rem;
}
/* Ranking */
.ranking-bar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1rem 1.5rem;
background: var(--theme-tarjeta);
border-radius: 12px;
margin-bottom: 2rem;
}
.ranking-item {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.ranking-label {
font-size: 0.8rem;
color: var(--theme-texto);
opacity: 0.7;
}
.ranking-value {
font-size: 1.25rem;
font-weight: 700;
color: #C9A751;
}
.ranking-total {
font-size: 0.8rem;
color: var(--theme-texto);
opacity: 0.5;
}
.ranking-sep {
width: 1px;
height: 24px;
background: var(--theme-borde);
}
/* Secciones */
.seccion {
margin-bottom: 2.5rem;
}
.seccion-titulo {
font-size: 1.1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 1rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.gestion-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.6rem;
background: rgba(201, 167, 81, 0.15);
color: #C9A751;
border-radius: 4px;
}
/* Historia */
.historia-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.historia-grid.single {
grid-template-columns: 1fr;
}
.historia-card {
background: var(--theme-tarjeta);
border-radius: 12px;
padding: 1rem;
}
.historia-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.75rem;
}
.historia-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-texto);
}
.historia-monto {
font-size: 0.95rem;
font-weight: 600;
}
.historia-monto.ingresos {
color: #4A8B6E;
}
.historia-monto.gastos {
color: #C9A751;
}
.historia-chart {
width: 100%;
height: 180px;
}
/* Clasificadores */
.clasificadores-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.clasificador-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.clasificador-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.clasificador-info {
display: flex;
align-items: baseline;
gap: 0.4rem;
flex-wrap: wrap;
}
.clasificador-monto {
font-size: 0.95rem;
font-weight: 600;
color: var(--theme-titulo);
}
.clasificador-padre {
font-size: 0.8rem;
font-weight: 400;
color: var(--theme-texto);
opacity: 0.5;
}
.clasificador-nombre {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-titulo);
}
.clasificador-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.6;
flex-shrink: 0;
}
.barra-container {
width: 100%;
height: 48px;
border-radius: 4px;
overflow: hidden;
}
/* Responsive */
@media (max-width: 768px) {
.dashboard {
padding: 1rem;
}
.sticky-header {
margin: 0 -1rem 1rem -1rem;
padding: 0.5rem 1rem;
}
.sticky-row {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
max-width: calc(100% - 160px);
}
.sticky-left {
flex-direction: column;
gap: 0.25rem;
}
.entidad-nombre {
font-size: 0.95rem;
}
.sticky-controls {
width: 100%;
}
.control-select {
flex: 1;
}
.historia-grid {
grid-template-columns: 1fr;
}
.ranking-bar {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.ranking-sep {
width: 100%;
height: 1px;
}
}
</style>
import { supabase } from '$lib/supabase';
// Hardcoded a objeto 11700 (Sueldos) para testing de layout
const CODIGO = '11700';
function getGrupoCode(objeto) { return objeto.charAt(0) + '0000'; }
function getSubgrupoCode(objeto) { return objeto.substring(0, 2) + '000'; }
function getPartidaCode(objeto) { return objeto.substring(0, 3) + '00'; }
export async function load() {
const { data } = await supabase.schema('ppto').from('clas_objetos').select('*').eq('objeto', CODIGO);
const objeto = data[0];
const grupoCode = getGrupoCode(CODIGO);
const subgrupoCode = getSubgrupoCode(CODIGO);
const partidaCode = getPartidaCode(CODIGO);
const { data: padresData } = await supabase.schema('ppto').from('clas_objetos').select('*').in('objeto', [grupoCode, subgrupoCode, partidaCode]).order('objeto');
const padres = padresData || [];
// Contar entidades
const nEntidades = objeto.n_entidades || 0;
return { objeto, padres, hijos: [], nEntidades };
}
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import { initIndex } from '$lib/stores/searchStore';
let { data } = $props();
let objetoData = $derived(data.objeto);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(true);
let hoveredYear = $state(null);
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(data.nEntidades);
// Datos
let datosAnuales = $state([]);
let datosHistorico = $state(null);
let topEntidadesPorAño = $state({});
const POBLACION = 12000000;
let objetoCodigo = $derived(objetoData.objeto);
let objetoNivel = $derived(objetoData.nivel);
let gastoPerCapita = $derived(
datosAnuales.map(d => ({
año: d.gestion,
monto: selectedEntity ? (d.monto ?? d.devengado) : d.total,
perCapita: d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)
}))
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : 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 currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
let displayRankingLabel = $derived(currentData ? `partidas más gastadas en ${currentData.año}` : 'partidas más gastadas');
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
{ nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
{ nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(displayMonto); });
$effect(() => { twPerCapita.set(displayPerCapita); });
$effect(() => { twPorcentaje.set(displayPorcentaje); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
loading = true;
if (selectedEntity) {
const { data } = 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 = {};
}
} else {
const { data } = 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);
topEntidadesPorAño = {};
datosAnuales.forEach(d => {
topEntidadesPorAño[d.gestion] = [
{ nombre: d.top1_entidad, porcentaje: d.top1_pct },
{ nombre: d.top2_entidad, porcentaje: d.top2_pct },
{ nombre: d.top3_entidad, porcentaje: d.top3_pct },
].filter(e => e.nombre);
});
}
}
loading = false;
}
async function loadEntidades() {
const { data } = 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;
}
onMount(async () => {
mounted = true;
initIndex();
await Promise.all([loadData(), loadEntidades()]);
});
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<nav class="page-nav">
<a href="/clasificadores/objeto-gasto" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context">
<div class="context-card">
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<p class="context-hint">Buscar otro grupo, subgrupo, partida o subpartida</p>
<ObjetoSearch />
</div>
<div class="context-separator"></div>
<!-- Navegación -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegación</h3>
<p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.objeto}</span>
<span class="tree-name">{padre.desc_objeto}</span>
</a>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{objetoData.objeto}</span>
<span class="tree-name">{objetoData.desc_objeto}</span>
</div>
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.objeto}</span>
<span class="tree-name">{hijo.desc_objeto}</span>
</a>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<div class="explore-scroll">
<a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en sueldos, el Min. de Salud o Defensa?</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
</svg>
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en esta partida</span>
</a>
<a href="/ubicacion" class="explore-card explore-card-disabled">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en esta partida</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
</svg>
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesan los sueldos respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
</svg>
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todas las partidas de gasto y navegar entre ellas</span>
</a>
</div>
</div>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el período</span>
<span class="vigencia-sub">2005 – 2025</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Definición -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
<button class="def-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
Ver variaciones en la definición
</button>
</div>
</div>
</aside>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">Subpartida</span>
<span class="pill pill-codigo">{objetoData.objeto}</span>
</div>
</div>
<h2 class="main-title-name">{objetoData.desc_objeto}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
</div>
<div class="chart-header">
<span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<h3 class="ranking-title">Concentración del gasto</h3>
<p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<div class="ranking-card">
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
</div>
{/each}
</div>
</div>
{/if}
</main>
</div>
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
padding: 2rem 2rem 0.5rem;
max-width: 1400px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.back-link:hover { color: var(--theme-accent); }
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem 2rem 2rem;
height: calc(100vh - 4rem);
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1.375rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: #666666;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-watermark {
opacity: 0.18;
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-watermark {
position: absolute;
bottom: -4px;
right: -4px;
width: 44px;
height: 44px;
opacity: 0.15;
pointer-events: none;
color: var(--theme-accent, #C9A751);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
.explore-card-disabled {
opacity: 0.3;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-sub {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
/* ═══════════════════════════════════════════════════════════════
DEFINICIÓN
═══════════════════════════════════════════════════════════════ */
.def-link {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 1.75rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.2;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.kpi-value {
font-family: 'DM Mono', monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.5625rem;
color: var(--theme-texto);
opacity: 0.6;
line-height: 1.3;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-desc {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ranking-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: var(--theme-borde);
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
flex: 1;
font-size: 0.75rem;
color: var(--theme-titulo);
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
}
/* ═══════════════════════════════════════════════════════════════
DARK MODE
═══════════════════════════════════════════════════════════════ */
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.4);
}
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
</style>
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código objeto
function getGrupoCode(objeto) {
return objeto.charAt(0) + '0000';
}
function getSubgrupoCode(objeto) {
return objeto.substring(0, 2) + '000';
}
function getPartidaCode(objeto) {
return objeto.substring(0, 3) + '00';
}
function getNivel(objeto) {
if (objeto.endsWith('0000')) return 'grupo';
if (objeto.endsWith('000')) return 'subgrupo';
if (objeto.endsWith('00')) return 'partida';
return 'subpartida';
}
export async function load({ params }) {
console.time('[SERVER] Total load objeto');
const { codigo } = params;
console.time('[SERVER] Query clas_objetos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', codigo);
console.timeEnd('[SERVER] Query clas_objetos');
if (dbError || !data || data.length === 0) {
throw error(404, 'Objeto no encontrado');
}
// Tomar el primer resultado
const objeto = data[0];
const nivel = objeto.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
// Buscar padres según nivel
if (nivel === 'subpartida') {
// Padres: grupo, subgrupo, partida
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const partidaCode = getPartidaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode, partidaCode])
.order('objeto');
if (padresData) padres = padresData;
} else if (nivel === 'partida') {
// Padres: grupo, subgrupo
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode])
.order('objeto');
if (padresData) padres = padresData;
} else if (nivel === 'subgrupo') {
// Padre: grupo
const grupoCode = getGrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', grupoCode);
if (padresData) padres = padresData;
}
// grupo no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
if (nivel === 'grupo') {
// Hijos: subgrupos que empiecen con el mismo dígito
const prefix = codigo.charAt(0);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
} else if (nivel === 'subgrupo') {
// Hijos: partidas que empiecen con los mismos 2 dígitos
const prefix = codigo.substring(0, 2);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
} else if (nivel === 'partida') {
// Hijos: subpartidas que empiecen con los mismos 3 dígitos
const prefix = codigo.substring(0, 3);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subpartida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
}
// subpartida no tiene hijos
console.timeEnd('[SERVER] Query hijos');
console.timeEnd('[SERVER] Total load objeto');
return {
objeto,
padres,
hijos
};
}
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import { initIndex } from '$lib/stores/searchStore';
let { data } = $props();
let objetoData = $derived(data.objeto);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(true);
let hoveredYear = $state(null);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
// Parsear descripciones
function parseDescripciones(str) {
if (!str) return [];
try {
const parsed = JSON.parse(str);
return parsed.sort((a, b) => {
const yA = a.rangos?.match(/\d{4}/g) || [];
const yB = b.rangos?.match(/\d{4}/g) || [];
return Math.max(...yB.map(Number), 0) - Math.max(...yA.map(Number), 0);
});
} catch { return []; }
}
let descripciones = $derived(parseDescripciones(objetoData.descripciones));
let variaciones = $derived(descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion })));
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(data.nEntidades);
// Datos
let datosAnuales = $state([]);
let datosHistorico = $state(null);
let topEntidadesPorAño = $state({});
const POBLACION = 12000000;
let objetoCodigo = $derived(objetoData.objeto);
let objetoNivel = $derived(objetoData.nivel);
let gastoPerCapita = $derived(
datosAnuales.map(d => {
const monto = selectedEntity ? (d.monto ?? d.devengado) : d.total;
const perCapita = d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0);
return {
año: d.gestion,
monto,
perCapita: metrica === 'percapita' ? perCapita : monto
};
})
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : 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 currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
let nivelKey = $derived(objetoData.nivel?.toLowerCase());
let nivelPlural = $derived({ grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' }[nivelKey] || 'objetos');
let nivelLabel = $derived({ grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }[nivelKey] || objetoData.nivel);
let displayRankingLabel = $derived(currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`);
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
{ nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
{ nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(displayMonto); });
$effect(() => { twPerCapita.set(displayPerCapita); });
$effect(() => { twPorcentaje.set(displayPorcentaje); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
loading = true;
if (selectedEntity) {
const { data } = 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 = {};
}
} else {
const { data } = 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);
topEntidadesPorAño = {};
datosAnuales.forEach(d => {
topEntidadesPorAño[d.gestion] = [
{ nombre: d.top1_entidad, porcentaje: d.top1_pct },
{ nombre: d.top2_entidad, porcentaje: d.top2_pct },
{ nombre: d.top3_entidad, porcentaje: d.top3_pct },
].filter(e => e.nombre);
});
}
}
loading = false;
}
async function loadEntidades() {
const { data } = 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;
}
onMount(async () => {
mounted = true;
initIndex();
await Promise.all([loadData(), loadEntidades()]);
});
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<nav class="page-nav">
<a href="/clasificadores/objeto-gasto" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
<!-- Mobile toggle -->
<button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
</button>
<div class="context-card">
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<p class="context-hint">Buscar otro objeto de gasto</p>
<ObjetoSearch />
</div>
<div class="context-separator"></div>
<!-- Navegación -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegación</h3>
<p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.objeto}</span>
<span class="tree-name">{padre.desc_objeto}</span>
</a>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{objetoData.objeto}</span>
<span class="tree-name">{objetoData.desc_objeto}</span>
</div>
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.objeto}</span>
<span class="tree-name">{hijo.desc_objeto}</span>
</a>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<div class="explore-scroll">
<a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
</svg>
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/ubicacion" class="explore-card explore-card-disabled">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
</svg>
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
</svg>
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todos los objetos de gasto y navegar entre ellos</span>
</a>
</div>
</div>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el período</span>
<span class="vigencia-sub">2005 – 2025</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Definición -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
<button class="def-link" onclick={() => showDefinicionesModal = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
{#if variaciones.length === 1}
Ver definición oficial
{:else}
Ver {variaciones.length} variaciones en la definición
{/if}
</button>
</div>
</div>
</aside>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{objetoData.objeto}</span>
</div>
</div>
<h2 class="main-title-name">{objetoData.desc_objeto}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
</div>
<div class="chart-header">
<span class="chart-title">
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<span class="chart-period">· {displayPeriodo}</span>
</span>
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Ver monto real' : 'Ver per cápita'}
</button>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<h3 class="ranking-title">Concentración del gasto</h3>
<p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<div class="ranking-card">
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
</div>
{/each}
</div>
</div>
{/if}
</main>
</div>
<!-- Modal de definiciones -->
{#if showDefinicionesModal}
<div class="modal-overlay" onclick={() => showDefinicionesModal = false}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Definiciones de "{objetoData.desc_objeto}"</h3>
<button class="modal-close" onclick={() => showDefinicionesModal = false}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
{#each variaciones as v, i}
<div class="def-item" class:def-current={i === 0}>
<div class="def-header">
<span class="def-period">{v.rango}</span>
{#if i === 0}
<span class="def-badge">Vigente</span>
{/if}
</div>
<p class="def-text">{v.texto}</p>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
padding: 2rem 2rem 0.5rem;
max-width: 1400px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.back-link:hover { color: var(--theme-accent); }
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 30px 2rem;
height: calc(100vh - 4rem);
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-watermark {
opacity: 0.18;
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-watermark {
position: absolute;
bottom: -4px;
right: -4px;
width: 44px;
height: 44px;
opacity: 0.15;
pointer-events: none;
color: var(--theme-accent, #C9A751);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
.explore-card-disabled {
opacity: 0.3;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-sub {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
}
/* ═══════════════════════════════════════════════════════════════
DEFINICIÓN
═══════════════════════════════════════════════════════════════ */
.def-link {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.15;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.metrica-toggle {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
border: 1px solid rgba(201, 167, 81, 0.2);
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.metrica-toggle:hover {
background: rgba(201, 167, 81, 0.2);
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
text-align: center;
}
.kpi-value {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-desc {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
margin: 0 0 0.5rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ranking-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: rgba(128, 128, 128, 0.06);
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
flex: 1;
font-size: 0.8125rem;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-titulo);
}
/* ═══════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--theme-surface);
border-radius: 16px;
max-width: 560px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--theme-borde);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.modal-close {
color: var(--theme-texto);
background: transparent;
border: none;
padding: 0.25rem;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.modal-close:hover {
background: var(--theme-borde);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.def-item {
padding: 1rem;
background: var(--theme-body);
border-radius: 10px;
border-left: 3px solid var(--theme-borde);
}
.def-current {
border-left-color: #6B9FD4;
}
.def-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.def-period {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
color: #6B9FD4;
}
.def-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
background: #4A9E6B;
padding: 0.125rem 0.5rem;
border-radius: 4px;
}
.def-text {
font-size: 0.875rem;
color: var(--theme-texto);
line-height: 1.6;
margin: 0;
}
:global(html.dark) .def-current {
border-left-color: var(--theme-accent);
}
:global(html.dark) .def-period {
color: var(--theme-accent);
}
/* ═══════════════════════════════════════════════════════════════
MOBILE SIDEBAR
═══════════════════════════════════════════════════════════════ */
.mobile-sidebar-toggle {
display: none;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
gap: 0.5rem;
height: auto;
overflow: visible;
padding: 1.5rem 1rem 2rem;
align-content: center;
min-height: calc(100vh - 4rem);
}
.dashboard-main {
overflow: visible;
}
.chart-protagonista {
flex: none;
}
.ranking-section {
flex: none;
}
.mobile-sidebar-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
color: var(--theme-titulo);
font-family: 'Qanelas', var(--font-sans);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.25rem;
width: 100%;
}
.mobile-sidebar-toggle:hover {
background: var(--theme-surface-hover);
}
.mobile-sidebar-toggle svg {
color: var(--theme-texto);
opacity: 0.6;
}
.dashboard-context .context-card {
display: none;
}
.dashboard-context.mobile-open .context-card {
display: flex;
animation: slideDown 0.25s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.context-card {
height: auto;
overflow-y: visible;
}
.page-nav {
padding: 0.75rem 1rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.dashboard-layout {
padding: 0.5rem;
}
.page-nav {
padding: 0.5rem;
}
.chart-protagonista,
.ranking-section,
.context-card {
padding: 0.5rem;
border-radius: 8px;
}
.main-title-name {
font-size: 1.375rem;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.kpi-value {
font-size: 0.8125rem;
}
.kpi-label {
font-size: 0.5625rem;
}
.explore-scroll {
gap: 6px;
}
.explore-card {
width: 100px;
height: 80px;
}
.explore-title {
font-size: 0.625rem;
}
.explore-desc {
font-size: 0.5rem;
}
.ranking-title {
font-size: 0.875rem;
}
.ranking-name {
font-size: 0.6875rem;
}
.ranking-pct {
font-size: 0.75rem;
}
.modal-content {
margin: 0.5rem;
max-height: 90vh;
}
.tree-name {
max-width: 150px;
}
}
/* ═══════════════════════════════════════════════════════════════
DARK MODE
═══════════════════════════════════════════════════════════════ */
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.5);
}
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
</style>
<script>
import { onMount } from 'svelte';
import * as d3 from 'd3';
let mapaData = $state(null);
let boliviaPath = $state('');
let deptoPath = $state('');
let muniPath = $state('');
let error = $state('');
let debug = $state('');
onMount(async () => {
try {
const res = await fetch('/mapa.json');
mapaData = await res.json();
debug = `bolivia: ${mapaData.bolivia.features.length} features, `;
debug += `deptos: ${mapaData.departamentos.features.length}, `;
debug += `munis: ${mapaData.municipios.features.length}`;
// Quitar CRS y corregir winding order
delete mapaData.bolivia.crs;
delete mapaData.departamentos.crs;
delete mapaData.municipios.crs;
// Corregir winding order: invertir todos los anillos
function fixWinding(geojson) {
const fix = (coords, isHole) => {
// Para geo coords: exterior debe ser counterclockwise (area < 0 en D3)
// Simplemente invertimos todos los anillos exteriores
coords.forEach((ring, i) => {
if (i === 0) ring.reverse(); // exterior
// holes quedan como están
});
};
for (const feat of geojson.features || [geojson]) {
const geom = (feat.geometry || feat);
if (geom.type === 'Polygon') {
fix(geom.coordinates);
} else if (geom.type === 'MultiPolygon') {
geom.coordinates.forEach(poly => fix(poly));
}
}
}
fixWinding(mapaData.bolivia);
fixWinding(mapaData.departamentos);
fixWinding(mapaData.municipios);
const bounds = d3.geoBounds(mapaData.bolivia);
debug += ` | bounds: ${JSON.stringify(bounds)}`;
const projection = d3.geoMercator().fitSize([380, 480], mapaData.bolivia);
const pathGen = d3.geoPath(projection);
boliviaPath = pathGen(mapaData.bolivia) || '';
debug += ` | boliviaPath length: ${boliviaPath.length}`;
deptoPath = mapaData.departamentos.features.map(f => pathGen(f)).join(' ');
debug += ` | deptoPath length: ${deptoPath.length}`;
const muni = mapaData.municipios.features[0];
muniPath = pathGen(muni) || '';
debug += ` | muni code: ${muni.properties.codigo}`;
} catch (e) {
error = e.message;
}
});
</script>
<div style="padding: 2rem; background: #1C1C1A; color: #F5F0E8; min-height: 100vh;">
<h1>Test Mapa</h1>
<p style="font-size: 12px; color: #888;">{debug}</p>
{#if error}
<p style="color: red;">{error}</p>
{/if}
<svg width="400" height="500" viewBox="0 0 400 500" style="border: 1px solid #333;">
{#if boliviaPath}
<path d={boliviaPath} fill="none" stroke="#555" stroke-width="1" />
{/if}
{#if deptoPath}
<path d={deptoPath} fill="rgba(255,255,255,0.1)" stroke="#777" stroke-width="0.5" />
{/if}
{#if muniPath}
<path d={muniPath} fill="#D4A574" stroke="#D4A574" stroke-width="1" />
{/if}
</svg>
</div>
import { supabase } from '$lib/supabase';
export const ssr = false;
export async function load({ fetch }) {
// Cargar resumen y población en paralelo
const [resumenRes, pobRes] = await Promise.all([
supabase
.schema('ppto')
.from('entidad_resumen')
.select('codigo, desc, gestion, devengado, ranking')
.eq('tipo', 'gastos')
.eq('tipo_codigo', 'municipio_ubigeo')
.eq('gestion', 2025)
.order('devengado', { ascending: false })
.limit(500),
fetch('/poblacion.csv').then(r => r.text())
]);
const pobRes = await fetch('/poblacion.csv').then(r => r.text());
const poblacionMap = {};
pobRes.split('\n').slice(1).forEach(line => {
......@@ -27,7 +14,7 @@ export async function load({ fetch }) {
const gestiones = [2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025];
return {
resumen: resumenRes.data || [],
resumen: [],
poblacionMap,
gestiones,
gestionInicial: 2025
......
......@@ -4,7 +4,6 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
import { mapaCache } from '$lib/stores/mapaCache';
let { data } = $props();
......@@ -38,6 +37,22 @@
let clasSearchVal = $state('');
let clasSearchResults = $state([]);
let clasSearchFocused = $state(false);
let clasSelectedIdx = $state(-1);
function handleClasKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
clasSelectedIdx = clasSelectedIdx < clasSearchResults.length - 1 ? clasSelectedIdx + 1 : 0;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
clasSelectedIdx = clasSelectedIdx > 0 ? clasSelectedIdx - 1 : clasSearchResults.length - 1;
} else if (e.key === 'Enter' && clasSelectedIdx >= 0) {
e.preventDefault();
seleccionarClasificador(clasSearchResults[clasSelectedIdx]);
} else if (e.key === 'Escape') {
clasSearchFocused = false;
}
}
let clasSearchLoading = $state(false);
let clasSeleccionado = $state(null);
let clasDebounce;
......@@ -89,6 +104,7 @@
function handleClasInput(e) {
clasSearchVal = e.target.value;
clasSelectedIdx = -1;
clearTimeout(clasDebounce);
if (clasSearchVal.length >= 2) {
clasDebounce = setTimeout(() => buscarClasificador(clasSearchVal), 200);
......@@ -362,20 +378,9 @@
return;
}
// Modo total: cargar desde Supabase
if (!resumenCache[gestion]) {
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_resumen')
.select('codigo, desc, gestion, devengado, ranking')
.eq('tipo', 'gastos')
.eq('tipo_codigo', 'municipio_ubigeo')
.eq('gestion', gestion)
.order('devengado', { ascending: false })
.limit(500);
resumenCache[gestion] = rows || [];
}
resumen = resumenCache[gestion];
// Modo total: datos no disponibles sin Supabase
// TODO: implementar endpoint API para resumen por municipio
resumen = [];
poblacionMap = await getPoblacionAno(gestion);
}
......@@ -588,6 +593,7 @@
type="text"
value={clasSearchVal}
oninput={handleClasInput}
onkeydown={handleClasKeydown}
onfocus={() => clasSearchFocused = true}
onblur={() => setTimeout(() => clasSearchFocused = false, 150)}
placeholder="Buscar {clasificadorSeleccionado === 'objeto' ? 'partida' : clasificadorSeleccionado === 'finfun' ? 'finalidad o función' : 'sector'}..."
......@@ -599,8 +605,8 @@
{:else if clasSearchResults.length === 0}
<div class="objeto-search-msg">Sin resultados</div>
{:else}
{#each clasSearchResults as result}
<button class="objeto-search-item" onclick={() => seleccionarClasificador(result)}>
{#each clasSearchResults as result, i}
<button class="objeto-search-item" class:active={clasSelectedIdx === i} onclick={() => seleccionarClasificador(result)}>
<span class="objeto-search-code">{result.codigo}</span>
<span class="objeto-search-name">{@html result.highlight}</span>
</button>
......