Rafael Lopez

nuevo landing

{
"facet_counts": [],
"found": 49,
"out_of": 735538,
"page": 1,
"search_time_ms": 96,
"hits": [
{
"document": {
"gestion": 2017,
"devengado": 6544172.35,
"is_class": false,
"class_": "programa_proyecto",
"texto": "CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA LINERA-COPALANI",
"metadatos": {
"entidad": 1260,
"programa": 21,
"proyecto": "112",
"actividad": 0.0
}
},
"highlights": [
{
"field": "texto",
"matched_tokens": ["LINERA"],
"snippet": "CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA <mark>LINERA</mark>-COPALANI"
}
]
},
{
"document": {
"gestion": 2018,
"devengado": 6500010.17,
"is_class": false,
"class_": "programa_proyecto",
"texto": "CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA LINERA",
"metadatos": {
"entidad": 1347,
"programa": 21,
"proyecto": "110",
"actividad": 0.0
}
},
"highlights": [
{
"field": "texto",
"matched_tokens": ["LINERA"],
"snippet": "CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA <mark>LINERA</mark>"
}
]
}
]
}
\ No newline at end of file
# Refactor del Buscador - Landing Page
## Naturaleza del Proyecto
**¿Para quién?** Ciudadanos bolivianos (público general, no técnicos)
**¿De quién?** Gobierno boliviano - Ministerio de Economía y Finanzas Públicas
**¿Qué es?** Datos abiertos de contabilidad fiscal (presupuesto público)
**El mensaje central**: "Puedes buscar donde quieras" - transparencia, accesibilidad, apertura
El buscador de la landing actual es un **statement visual** de ese mensaje. El nuevo buscador debe mantener ese espíritu.
---
## Nueva Ruta: `/nuevo-buscador`
Ruta dedicada al buscador completo con todas las capacidades:
- Selector Ingresos/Gastos
- Selector Programas/Clasificadores (solo gastos)
- Selector de clasificador específico
- Resultados ricos tipo Spotlight
**Archivos**:
```
src/routes/nuevo-buscador/
└── +page.svelte
```
---
## Command K (Buscador Global) - EFECTO DEL REFACTOR
### Descripción
Hay un buscador modal global (tipo Spotlight) que se activa con `Cmd+K` / `Ctrl+K` desde cualquier página. Este componente **debe sincronizarse** con las decisiones del buscador principal.
### Archivos involucrados
```
src/lib/components/ui/SearchModal.svelte # Componente del modal
src/routes/+layout.svelte # Monta el modal globalmente (línea 67)
src/lib/components/ui/Navbar.svelte # Botón para abrir el modal
src/lib/stores/searchStore.js # Store compartido con landing
```
### Implementación actual
- **Activación**: `Cmd+K` / `Ctrl+K` (definido en `+layout.svelte:23-28`)
- **También**: Botón en el Navbar (`onOpenSearch`)
- **Filtros actuales**: 6 clasificadores (entidad, objeto_gasto, rubro, finfun, organismo, fuente)
- **Store**: Usa `searchStore.js` (compartido con landing)
- **NO tiene**: Selector Ingresos/Gastos, Programas/Proyectos
### Lo que debe cambiar con el refactor
1. Agregar selector Ingresos/Gastos
2. Agregar opción Programas/Proyectos (solo en Gastos)
3. Sincronizar filtros con el buscador principal
4. Posiblemente: recordar última selección del usuario
5. Mostrar las 3 variaciones de diseño de resultados
### Flujo deseado post-refactor
```
Usuario en cualquier página
[Cmd+K]
┌─────────────────────────────────────┐
│ SearchModal (replica del central) │
│ │
│ [Ingresos] [Gastos] │
│ [Programas] [Clasificadores] │
│ │
│ Resultados según selección... │
└─────────────────────────────────────┘
```
---
## Conceptos Clave
### Selector Central: Ingresos vs Gastos
Este es el **eje fundamental** que contextualiza toda búsqueda. No es un filtro opcional - define el universo de datos.
### Clasificadores Compartidos (con contexto distinto)
| Clasificador | En INGRESOS significa | En GASTOS significa |
|--------------|----------------------|---------------------|
| **Geográfico** (dónde) | ¿De dónde vienen los ingresos? | ¿Dónde se ejecuta el gasto? |
| **Entidades** (quién) | ¿Quién recibe? | ¿Quién ejecuta? |
---
## Árbol de Decisiones de Búsqueda
```
┌─────────────────┐
│ DEFAULT │
│ Gastos+Programas│
└────────┬────────┘
┌───────────────┴───────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ INGRESOS │ │ GASTOS │
└───────┬───────┘ └───────┬───────┘
│ │
│ ┌───────┴───────┐
│ ▼ ▼
│ ┌─────────────┐ ┌─────────────┐
│ │ PROGRAMAS/ │ │ CLASIFIC. │
│ │ PROYECTOS │ │ │
│ │ (~600k) │ │ │
│ └─────────────┘ └──────┬──────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ CLASIFICADORES│ │ CLASIFICADORES│
│ INGRESOS │ │ GASTOS │
└───────┬───────┘ └───────┬───────┘
│ │
┌────────────┼────────────┐ ┌─────────────┼─────────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Rubro │ │Organismo │ │ Fuente │ │ Fin/Fun │ │Obj.Gasto │ │ Sectores │
└────────┘ └──────────┘ └────────┘ └──────────┘ └──────────┘ └──────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Geográfico │ (compartido) │ Geográfico │
│ (dónde) │ │ (dónde) │
└───────────────┘ └───────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Entidades │ (compartido) │ Entidades │
│ (quién) │ │ (quién) │
└───────────────┘ └───────────────┘
```
---
## Clasificadores por Modo
| INGRESOS (5) | GASTOS (5) |
|--------------|------------|
| Rubro | Finalidad y Función |
| Organismo | Objetos de Gasto |
| Fuente | Sectores |
| Geográfico *(dónde)* | Geográfico *(dónde)* |
| Entidades *(quién)* | Entidades *(quién)* |
**Nota**: Geográfico y Entidades aparecen en ambos pero la vista enfatiza el contexto (ingresos o gastos) según la rama seleccionada.
---
## Tabla de Todas las Búsquedas Posibles
| # | Modo | Submodo | Clasificador | Qué busca |
|---|------|---------|--------------|-----------|
| **0** | **DEFAULT** | - | - | **Gastos + Programas/Proyectos** |
| **INGRESOS** |||||
| 1 | Ingresos | Clasif. | Todos | 5 clasificadores de ingresos |
| 2 | Ingresos | Clasif. | Rubro | Solo rubros |
| 3 | Ingresos | Clasif. | Organismo | Solo organismos |
| 4 | Ingresos | Clasif. | Fuente | Solo fuentes |
| 5 | Ingresos | Clasif. | Geográfico | Geográfico (contexto ingresos) |
| 6 | Ingresos | Clasif. | Entidades | Entidades (contexto ingresos) |
| **GASTOS** |||||
| 7 | Gastos | **Prog/Proy** | - | ~600k registros narrativos |
| 8 | Gastos | Clasif. | Todos | 5 clasificadores de gastos |
| 9 | Gastos | Clasif. | Finalidad/Función | Solo finfun |
| 10 | Gastos | Clasif. | Objeto de gasto | Solo objetos |
| 11 | Gastos | Clasif. | Sectores | Solo sectores |
| 12 | Gastos | Clasif. | Geográfico | Geográfico (contexto gastos) |
| 13 | Gastos | Clasif. | Entidades | Entidades (contexto gastos) |
**Total: 14 formas de búsqueda** (1 default + 6 ingresos + 7 gastos)
---
## Comportamiento Default
```
┌─────────────────────────────────────────────────────────┐
│ [🔍 Buscar...] │
│ │
│ SIN SELECCIONAR NADA: │
│ → Busca en GASTOS + PROGRAMAS/PROYECTOS │
│ → Lo más intuitivo para el ciudadano común │
│ │
└─────────────────────────────────────────────────────────┘
```
**Razón**: Los programas/proyectos tienen nombres narrativos como "CONST. PISCINA OLIMPICA MUNICIPAL" que coinciden con cómo la gente piensa sobre el gasto público.
---
## Las 3 Variaciones de Diseño
Aunque hay 14 formas de búsqueda posibles, se reducen a **3 variaciones de diseño** de resultados:
| Variación | Cuándo aplica | Características |
|-----------|---------------|-----------------|
| **1. Programas puro** | Default, Gastos + Programas | Cards narrativas ricas, contexto amplio |
| **2. Clasificador individual** | Cualquier clasificador específico | Lista estructurada, jerárquica |
| **3. Todos los clasificadores** | Ingresos/Gastos + "Todos" | Resultados agrupados por tipo |
---
### Variación 1: PROGRAMAS PURO
**Aplica cuando**: Default, o Gastos + Programas/Proyectos
**Características**:
- Texto narrativo libre (~600k registros)
- Cards ricas con mucho contexto
- Información: entidad, ubicación, monto, período, ejecución
```
┌─────────────────────────────────────────────────────────┐
│ 🔍 piscina olimpica │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ CONST. PISCINA OLIMPICA MUNICIPAL EVO MORALES │ │
│ │ ▸ Cobija · GAM Cobija · Bs 2.3M · 2019-2023 │ │
│ │ ████████░░ 80% ejecutado │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ PISCINA OLIMPICA DEPARTAMENTAL │ │
│ │ ▸ Tarija · Gobernación · Bs 890k · 2021 │ │
│ │ ███░░░░░░░ 30% ejecutado │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
---
### Variación 2: CLASIFICADOR INDIVIDUAL
**Aplica cuando**: Se selecciona UN clasificador específico (rubro, objeto, finfun, etc.)
**Características**:
- Lista estructurada y limpia
- Muestra jerarquía/nivel
- Código + descripción
```
┌─────────────────────────────────────────────────────────┐
│ 🔍 construccion [Objetos de gasto ▾] │
├─────────────────────────────────────────────────────────┤
│ │
│ 42000 · Construcciones y mejoras de bienes │
│ ▸ Partida · Nivel 2 │
│ │
│ 42100 · Construcciones de bienes de dominio público │
│ ▸ Partida específica · Nivel 3 │
│ │
│ 42200 · Construcciones de bienes de dominio privado │
│ ▸ Partida específica · Nivel 3 │
│ │
└─────────────────────────────────────────────────────────┘
```
---
### Variación 3: TODOS LOS CLASIFICADORES
**Aplica cuando**: Se selecciona "Todos" los clasificadores (de ingresos o de gastos)
**Características**:
- Resultados agrupados por tipo de clasificador
- Secciones visuales separadas
- Mezcla de estructuras
```
┌─────────────────────────────────────────────────────────┐
│ 🔍 educacion [Todos gastos ▾] │
├─────────────────────────────────────────────────────────┤
│ │
│ FINALIDAD Y FUNCIÓN │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 09 · Educación │ │
│ │ ▸ Finalidad │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ OBJETOS DE GASTO │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 25800 · Servicios de capacitación │ │
│ │ ▸ Partida │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ENTIDADES │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Min. de Educación │ │
│ │ ▸ Ministerio · MINEDU │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
---
## Arquitectura Técnica
### Motor de Búsqueda
**Typesense** para todo:
- Clasificadores (~1,400 items)
- Programas/Proyectos (~600,000 items)
### Parámetros de Typesense (ya implementados)
1. **Modo**: Ingresos vs Gastos
2. **Submodo**: Programas vs Clasificadores (solo gastos)
3. **Clasificador específico**: Cuál(es) clasificadores consultar
### Archivos Principales
```
src/routes/+page.svelte # Landing con buscador
src/lib/services/search.js # Lógica de búsqueda Typesense
src/lib/stores/searchStore.js # Estado reactivo
src/lib/components/search/
├── SearchBox.svelte # Input reutilizable
├── SearchResults.svelte # Dropdown resultados
└── SearchResultItem.svelte # Item individual
```
---
## Tareas de Implementación
### Fase 1: UI del Selector
- [ ] Diseñar selector Ingresos/Gastos (visible pero no obligatorio)
- [ ] Diseñar selector Programas/Clasificadores (solo si Gastos)
- [ ] Diseñar selector de clasificador específico
### Fase 2: Conexión UI ↔ Typesense
- [ ] Mapear estado de selectores a parámetros de Typesense
- [ ] Implementar búsqueda default (Gastos + Programas)
- [ ] Implementar cada una de las 14 formas de búsqueda
### Fase 3: Diseño de Resultados
- [ ] Diseñar cards ricas para Programas/Proyectos
- [ ] Diseñar cards para cada tipo de clasificador
- [ ] Implementar agrupación visual por tipo
### Fase 4: Testing
- [ ] Probar las 14 formas de búsqueda
- [ ] Probar UX del selector
- [ ] Probar performance con resultados mixtos
---
*Documento creado: 2026-04-04*
*Última actualización: 2026-04-04*
{"facet_counts":[],"found":49,"out_of":735538,"page":1,"search_time_ms":96,"hits":[{"document":{"gestion":2017,"devengado":6544172.35,"is_class":false,"class_":"programa_proyecto","texto":"CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA LINERA-COPALANI","metadatos":"{\"entidad\": 1260, \"programa\": 21, \"proyecto\": \"112\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA <mark>LINERA</mark>-COPALANI"}]},{"document":{"gestion":2018,"devengado":6500010.17,"is_class":false,"class_":"programa_proyecto","texto":"CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA LINERA","metadatos":"{\"entidad\": 1347, \"programa\": 21, \"proyecto\": \"110\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2019,"devengado":5553528.35,"is_class":false,"class_":"programa_proyecto","texto":"CONST. U.E. ALVARO GARCIA LINERA (MOLLE HUMA)","metadatos":"{\"entidad\": 1315, \"programa\": 21, \"proyecto\": \"013159003900000\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. U.E. ALVARO GARCIA <mark>LINERA</mark> (MOLLE HUMA)"}]},{"document":{"gestion":2018,"devengado":3614683.9000000004,"is_class":false,"class_":"programa_proyecto","texto":"CONST. AULAS, TINGLADO Y GRADERIAS U.E. ALVARO GARCIA LINERA","metadatos":"{\"entidad\": 1345, \"programa\": 21, \"proyecto\": \"4\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. AULAS, TINGLADO Y GRADERIAS U.E. ALVARO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2017,"devengado":3613875.74,"is_class":false,"class_":"programa_proyecto","texto":"CONST. AULAS, TINGLADO Y GRADERIAS U.E. ALVARO GARCIA LINERA","metadatos":"{\"entidad\": 1345, \"programa\": 21, \"proyecto\": \"22\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. AULAS, TINGLADO Y GRADERIAS U.E. ALVARO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2019,"devengado":3500005.9299999997,"is_class":false,"class_":"programa_proyecto","texto":"CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA LINERA","metadatos":"{\"entidad\": 1347, \"programa\": 21, \"proyecto\": \"013470026900000\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2018,"devengado":2975934.63,"is_class":false,"class_":"programa_proyecto","texto":"CONST. UNIDAD EDUC. MARCELO QUIROGA SANTA CRUZ BARRIO GARCIA LINERA","metadatos":"{\"entidad\": 1901, \"programa\": 21, \"proyecto\": \"49\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. UNIDAD EDUC. MARCELO QUIROGA SANTA CRUZ BARRIO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2017,"devengado":2673975.66,"is_class":false,"class_":"programa_proyecto","texto":"CONST. UNIDAD EDUCATIVA ALVARO GARCIA LINERA","metadatos":"{\"entidad\": 1305, \"programa\": 21, \"proyecto\": \"2\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. UNIDAD EDUCATIVA ALVARO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2018,"devengado":2080916.4999999998,"is_class":false,"class_":"programa_proyecto","texto":"CONST. DE 1 PUENTE TIPO CAJÓN SOBRE EL ARROYO GARCÍA LINERA ","metadatos":"{\"entidad\": 909, \"programa\": 14, \"proyecto\": \"5\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. DE 1 PUENTE TIPO CAJÓN SOBRE EL ARROYO GARCÍA <mark>LINERA</mark> "}]},{"document":{"gestion":2018,"devengado":1344102.25,"is_class":false,"class_":"programa_proyecto","texto":"CONST. U.E. ALVARO GARCIA LINERA (MOLLE HUMA)","metadatos":"{\"entidad\": 1315, \"programa\": 21, \"proyecto\": \"4\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. U.E. ALVARO GARCIA <mark>LINERA</mark> (MOLLE HUMA)"}]},{"document":{"gestion":2018,"devengado":1329560.05,"is_class":false,"class_":"programa_proyecto","texto":"CONST. EDIFICIO MULTIFUNCIONAL MUNICIPAL LIC. ALVARO GARCIA LINERA - OMEREQUE","metadatos":"{\"entidad\": 1309, \"programa\": 17, \"proyecto\": \"3\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. EDIFICIO MULTIFUNCIONAL MUNICIPAL LIC. ALVARO GARCIA <mark>LINERA</mark> - OMEREQUE"}]},{"document":{"gestion":2016,"devengado":1187131.78,"is_class":false,"class_":"programa_proyecto","texto":"CONST. 8 AULAS U.E. ALVARO GARCIA LINERA - ENTRE RIOS","metadatos":"{\"entidad\": 1345, \"programa\": 21, \"proyecto\": \"18\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. 8 AULAS U.E. ALVARO GARCIA <mark>LINERA</mark> - ENTRE RIOS"}]},{"document":{"gestion":2016,"devengado":777315.61,"is_class":false,"class_":"programa_proyecto","texto":"CONST. OBRA DE TOMA SISTEMA AGUA POTABLE BARRIO GARCIA LINERA ZONA TERMINAL","metadatos":"{\"entidad\": 1901, \"programa\": 11, \"proyecto\": \"66\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. OBRA DE TOMA SISTEMA AGUA POTABLE BARRIO GARCIA <mark>LINERA</mark> ZONA TERMINAL"}]},{"document":{"gestion":2018,"devengado":763230.3600000001,"is_class":false,"class_":"programa_proyecto","texto":"CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA LINERA-COPALANI","metadatos":"{\"entidad\": 1260, \"programa\": 21, \"proyecto\": \"112\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA <mark>LINERA</mark>-COPALANI"}]},{"document":{"gestion":2018,"devengado":697049.14,"is_class":false,"class_":"programa_proyecto","texto":"CONST. UNIDAD EDUCATIVA ALVARO GARCIA LINERA","metadatos":"{\"entidad\": 1305, \"programa\": 21, \"proyecto\": \"2\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. UNIDAD EDUCATIVA ALVARO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2019,"devengado":591398.0800000001,"is_class":false,"class_":"programa_proyecto","texto":"CONST. UNIDAD EDUC. MARCELO QUIROGA SANTA CRUZ BARRIO GARCIA LINERA","metadatos":"{\"entidad\": 1901, \"programa\": 21, \"proyecto\": \"019010034700000\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. UNIDAD EDUC. MARCELO QUIROGA SANTA CRUZ BARRIO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2017,"devengado":458997.1,"is_class":false,"class_":"programa_proyecto","texto":"CONST. UNIDAD EDUC. MARCELO QUIROGA SANTA CRUZ BARRIO GARCIA LINERA","metadatos":"{\"entidad\": 1901, \"programa\": 21, \"proyecto\": \"49\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. UNIDAD EDUC. MARCELO QUIROGA SANTA CRUZ BARRIO GARCIA <mark>LINERA</mark>"}]},{"document":{"gestion":2016,"devengado":412433.03,"is_class":false,"class_":"programa_proyecto","texto":"CONST. SIST. AGUA POTABLE BARRIO GARCIA LINERA (COBIJA)","metadatos":"{\"entidad\": 1901, \"programa\": 11, \"proyecto\": \"9001\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. SIST. AGUA POTABLE BARRIO GARCIA <mark>LINERA</mark> (COBIJA)"}]},{"document":{"gestion":2019,"devengado":382206.88,"is_class":false,"class_":"programa_proyecto","texto":"CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA LINERA-COPALANI","metadatos":"{\"entidad\": 1260, \"programa\": 21, \"proyecto\": \"012600038300000\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA <mark>LINERA</mark>-COPALANI"}]},{"document":{"gestion":2017,"devengado":332310.79,"is_class":false,"class_":"programa_proyecto","texto":"CONST. EDIFICIO MULTIFUNCIONAL MUNICIPAL LIC. ALVARO GARCIA LINERA - OMEREQUE","metadatos":"{\"entidad\": 1309, \"programa\": 17, \"proyecto\": \"3\", \"actividad\": 0.0}"},"highlights":[{"field":"texto","matched_tokens":["LINERA"],"snippet":"CONST. EDIFICIO MULTIFUNCIONAL MUNICIPAL LIC. ALVARO GARCIA <mark>LINERA</mark> - OMEREQUE"}]}]}
\ No newline at end of file
......@@ -174,12 +174,9 @@
align-items: center;
gap: 0.25rem;
padding: 6px;
background: rgba(40, 40, 38, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
border: none;
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
.navbar-desktop {
......@@ -189,19 +186,15 @@
right: 1.5rem;
z-index: 110;
padding: 6px;
background: rgba(40, 40, 38, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
border: none;
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
:global(html:not(.dark)) .navbar-mobile,
:global(html:not(.dark)) .navbar-desktop {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
background: transparent;
border: none;
}
.navbar-desktop-inner {
......
......@@ -10,8 +10,8 @@
let { children } = $props();
// Don't show navbar on landing page
let isLanding = $derived($page.url.pathname === '/');
// Don't show navbar on landing pages
let isLanding = $derived($page.url.pathname === '/' || $page.url.pathname === '/nuevo-buscador');
// Global search modal state
let searchModalOpen = $state(false);
......
<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');
}
// Estado base
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
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 = [];
// Tracking para evitar re-animaciones
let seenHitIds = new Set();
let lastSearchKey = '';
function getHitKey(hit, index) {
const doc = hit.document;
const m = parseMetadatos(doc.metadatos);
// Key basado en contenido + índice como tiebreaker para evitar duplicados
if (doc.is_class === true || doc.is_class === 'true') {
return `c-${m.entidad || 'x'}-${doc.devengado || 0}-${index}`;
}
return `p-${m.entidad || 0}-${m.programa || 0}-${m.proyecto || 0}-${doc.gestion || 0}-${index}`;
}
function toggleGearMenu() {
gearMenuOpen = !gearMenuOpen;
function isNewHit(hit) {
const key = getHitKey(hit);
if (seenHitIds.has(key)) return false;
seenHitIds.add(key);
return true;
}
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;
function resetSeenHits() {
seenHitIds = new Set();
}
};
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 toggleSort(field) {
if (sortBy === field) {
sortOrder = sortOrder === 'desc' ? 'asc' : 'desc';
} else {
sortBy = field;
sortOrder = 'desc';
}
}
function toggleMobileSettings() {
mobileSettingsOpen = !mobileSettingsOpen;
$: 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;
}) : [];
// ¿Es búsqueda de entidades?
$: isEntidadSearchActive = results?.hits?.[0]?.document?.class_ === 'entidad';
// Agrupar por subarea cuando es búsqueda de entidades
$: groupedHits = (() => {
if (!isEntidadSearchActive || allHits.length === 0) return null;
const groups = {};
allHits.forEach(hit => {
const m = parseMetadatos(hit.document.metadatos);
const subarea = extractAfterHyphen(m.entidad_desc_subarea) || 'Otros';
if (!groups[subarea]) {
groups[subarea] = { name: subarea, hits: [], totalMonto: 0 };
}
groups[subarea].hits.push(hit);
groups[subarea].totalMonto += hit.document.devengado || 0;
});
// Ordenar items dentro de cada grupo
Object.values(groups).forEach(group => {
group.hits.sort((a, b) => {
const valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
const valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
});
// Convertir a array y ordenar grupos por monto total
return Object.values(groups).sort((a, b) => b.totalMonto - a.totalMonto);
})();
$: hasMoreResults = results && allHits.length < results.found;
$: 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' },
// 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 },
];
function switchMode(m) {
mode = m;
searchVal = '';
clearSearch();
if (searchInput) searchInput.value = '';
}
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 },
];
function handleBlur() {
setTimeout(() => { searchFocused = false; }, 200);
}
$: activeClasificadores = searchMode === 'gastos' ? CLASIFICADORES_GASTOS : CLASIFICADORES_INGRESOS;
function handleInput(e) {
const value = e.target.value;
searchVal = value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
performSearch(value);
}, 250);
function toggleClassifier(id) {
if (selectedClassifiers.includes(id)) {
selectedClassifiers = selectedClassifiers.filter(c => c !== id);
} else {
selectedClassifiers = [...selectedClassifiers, id];
}
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 setMode(mode) {
searchMode = mode;
selectedClassifiers = [];
if (mode === 'gastos') {
searchSubmode = 'programas';
} else {
searchSubmode = 'clasificadores';
}
}
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 setSubmode(sub) {
searchSubmode = sub;
selectedClassifiers = [];
}
function getTypeLabel(tipo) {
switch (tipo) {
case 'entidad': return 'ENTIDAD';
case 'objeto_gasto': return 'OBJETO';
case 'rubro': return 'RUBRO';
default: return tipo?.toUpperCase() || '';
// Placeholder dinámico
$: placeholderText = getPlaceholderText(searchMode, searchSubmode, selectedClassifiers);
function getPlaceholderText(mode, submode, classifiers) {
if (mode === 'gastos') {
if (submode === 'programas') return 'Buscar gastos en programas y proyectos...';
if (classifiers.length === 0) return 'Selecciona al menos un clasificador...';
const nombres = classifiers.map(id => {
const clf = CLASIFICADORES_GASTOS.find(c => c.id === id);
return clf?.label?.toLowerCase() || id;
}).join(', ');
return `Buscar gastos en ${nombres}...`;
} else {
if (classifiers.length === 0) return 'Selecciona al menos un clasificador...';
const nombres = classifiers.map(id => {
const clf = CLASIFICADORES_INGRESOS.find(c => c.id === id);
return clf?.label?.toLowerCase() || id;
}).join(', ');
return `Buscar ingresos en ${nombres}...`;
}
}
function getTypeColor(tipo) {
switch (tipo) {
case 'entidad': return '#4A8B6E';
case 'objeto_gasto': return '#C9A751';
case 'rubro': return '#8B6E9B';
default: return '#6B6860';
// Debounce
let debounceTimer;
let hintTimer;
let showMinLengthHint = false;
const DEBOUNCE_MS = 450;
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_PROGRAMAS = [
{ label: 'Vía férrea Bulo Bulo', color: 'rgba(232,146,124,0.22)', textColor: '#E8927C' },
{ label: 'Siderúrgica del Mutún', color: 'rgba(160,165,185,0.18)', textColor: '#B8BDCC' },
];
const EXAMPLES_ENTIDADES = [
{ label: 'Fondo Indígena', color: 'rgba(126,184,216,0.22)', textColor: '#8EC8E8' },
{ label: 'YPFB', color: 'rgba(168,196,160,0.22)', textColor: '#B8D4B0' },
];
// Ejemplos dinámicos según selección
$: currentExamples = (selectedClassifiers.includes('entidad') && selectedClassifiers.length === 1)
? EXAMPLES_ENTIDADES
: EXAMPLES_PROGRAMAS;
// Datos para secciones
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: 'fuentes', label: 'fuentes de financiamiento', d: 'TGN, crédito externo, donaciones', t: 'Fuentes', n: '22', href: '/clasificadores/fuentes' },
{ id: 'organismos', label: 'organismos financiadores', d: 'BID, Banco Mundial, cooperación', t: 'Organismos', n: '228', href: '/clasificadores/organismos' },
];
......@@ -201,25 +214,27 @@
{ 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: '#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.', 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: '#4A8B6E', title: 'El boom del gasto en infraestructura', desc: 'De 2006 a 2014 Bolivia multiplicó por 8 su inversión pública.', 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 },
{ color: '#4A8B6E', title: 'Evolución del gasto en salud', desc: 'Cómo cambiaron las prioridades.', 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' },
{ color: '#8B6E9B', title: 'Mapa de inversión', desc: 'Distribución geográfica de la inversión pública.', type: 'Mapa', href: null },
];
/* Reveal action */
// Hover states para secciones
let hDiario = false;
let hArchivo = false;
let hWidget = null;
let hHist = null;
let hVis = null;
// Reveal animation
let revealStates = {};
function reveal(node, { id, delay = 0 }) {
revealStates[id] = false;
......@@ -234,7 +249,7 @@
return { destroy() { obs.disconnect(); } };
}
/* Section navigation */
// Navegación interna - scroll a secciones
const SECTIONS_NAV = [
{ id: 'hero', label: 'Inicio' },
{ id: 'datos', label: 'Datos' },
......@@ -246,28 +261,231 @@
];
let currentSection = 'hero';
function scrollToSection(id) {
const el = document.querySelector(`[data-section="${id}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth' });
}
// Observer para actualizar sección activa
let sectionObserver;
onMount(() => {
setTimeout(() => { mounted = true; }, 80);
fetchDaily();
const isMobile = window.innerWidth < 768;
if (!isMobile) setTimeout(() => searchInput?.focus(), 300);
// IntersectionObserver para secciones
const sections = document.querySelectorAll('[data-section]');
const observer = new IntersectionObserver((entries) => {
sectionObserver = 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();
}, { threshold: 0.3 });
sections.forEach(section => sectionObserver.observe(section));
return () => sectionObserver?.disconnect();
});
function scrollToSection(id) {
const el = document.querySelector(`[data-section="${id}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth' });
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) {
// NO borrar allHits inmediatamente - mantener resultados anteriores visibles
if (page === 1) {
isLoading = true;
// Solo resetear sort en nueva búsqueda
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()
});
// Para clasificadores, pedir más resultados para tener conteos precisos
if (isClassSearch) {
params.set('per_page', '250');
}
if (isClassSearch && selectedClassifiers.length > 0) {
params.set('class_', selectedClassifiers.join(','));
}
console.log('[Search]', `${API_BASE}?${params}`);
const res = await fetch(`${API_BASE}?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
// Actualizar resultados DESPUÉS de recibir datos (no antes)
results = data;
if (page === 1) {
resetSeenHits();
allHits = data.hits;
} else {
allHits = [...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 {}; }
}
// Usar allHits para stats (no solo la última página)
function getStats(hits, totalFound) {
if (!hits?.length) return null;
const isClassifier = hits[0]?.document?.is_class === true;
const isEntidad = hits[0]?.document?.class_ === 'entidad';
const montoTotal = hits.reduce((s, h) => s + (h.document.devengado || 0), 0);
if (isClassifier) {
const entidadesSet = new Set();
hits.forEach(h => {
const m = parseMetadatos(h.document.metadatos);
if (m.entidad) entidadesSet.add(m.entidad);
});
return { found: totalFound, montoTotal, perCapita: montoTotal / POBLACION,
numEntidades: entidadesSet.size,
isClassifier: true, isEntidad, 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: totalFound, 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 = allHits.length > 0 ? getStats(allHits, results?.found || allHits.length) : 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 {
// No hay clasificadores seleccionados - limpiar resultados
results = null;
allHits = [];
}
}
}
// ¿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>
<!-- Section Navigation Dots -->
<!-- Dots de navegación -->
<nav class="section-dots">
{#each SECTIONS_NAV as s}
<button
......@@ -281,59 +499,39 @@
{/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 -->
<main data-section="hero">
<!-- ═══ 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 class="nav-logo">
<img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
</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"/>
<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>
<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">
<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}>
Información histórica y diaria de todo el Estado Boliviano.
</p>
<!-- Buscador -->
<div class="search-wrap" class:search-wrap-with-results={allHits.length > 0}>
<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"
......@@ -342,172 +540,324 @@
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => searchFocused = true}
on:blur={handleBlur}
disabled={!$indexLoaded}
on:blur={() => searchFocused = false}
placeholder=""
/>
{#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>
<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(); 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"/>
{#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 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 isLoading}
<div class="search-loading"><span class="loading-dot"></span></div>
{/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>
<!-- ─── FILTROS INLINE ─── -->
<div class="filters-row" class:filters-hidden={isSearching}>
<!-- Nivel 1: Segmented control Gastos / Ingresos -->
<div class="filter-level filter-level-1">
<div class="segmented-control">
<button
class="segment"
class:segment-active={searchMode === 'gastos'}
on:click={() => setMode('gastos')}
>
Gastos
</button>
<button
class="segment"
class:segment-active={searchMode === 'ingresos'}
on:click={() => setMode('ingresos')}
>
Ingresos
</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>
<!-- Nivel 2: Segmented control Programas / Clasificadores (solo Gastos) -->
{#if searchMode === 'gastos'}
<div class="segmented-control segmented-control-secondary">
<button
class="segment segment-secondary"
class:segment-active={searchSubmode === 'programas'}
on:click={() => setSubmode('programas')}
>
Programas y proyectos
</button>
<button
class="segment segment-secondary"
class:segment-active={searchSubmode === 'clasificadores'}
on:click={() => setSubmode('clasificadores')}
>
Clasificadores
</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}
<!-- Nivel 3: Clasificadores con checkboxes (cuando aplica) -->
{#if showClassifierChips}
<div class="filter-level filter-level-2">
<span class="filter-level-label">Selecciona clasificadores:</span>
<div class="checkbox-group">
{#each activeClasificadores as clf}
<button
type="button"
class="search-result-item"
class:result-selected={$selectedIndex === i}
on:click={() => handleSelect(item)}
class="checkbox-chip"
class:checkbox-chip-active={selectedClassifiers.includes(clf.id)}
class:checkbox-chip-disabled={clf.disabled}
on:click={() => { if (!clf.disabled) toggleClassifier(clf.id); }}
disabled={clf.disabled}
>
<div class="result-main">
<span class="result-name">{item.nombre}</span>
{#if item.sigla}
<span class="result-sigla">({item.sigla})</span>
<span class="checkbox-box">
{#if selectedClassifiers.includes(clf.id)}
<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="result-meta">
{#if item.codigo}
<span class="result-code">{item.codigo}</span>
</span>
<span class="checkbox-label">{clf.label}</span>
{#if clf.disabled}
<span class="chip-soon">próx.</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}
</div>
{#if $error}
<div class="search-error">{$error}</div>
{/if}
<!-- ─── ÁREA DE RESULTADOS / EJEMPLOS / HINT ─── -->
<div class="results-area">
<!-- 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"/>
<!-- Ejemplos -->
<div class="examples" class:examples-hidden={isSearching}>
<span class="examples-label">Ej.:</span>
{#each currentExamples 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>
<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
{/if}
<!-- Estados de búsqueda (mensajes) -->
{#if showMinLengthHint && searchVal.length > 0 && searchVal.length < 3}
<div class="results-message">
<div class="results-hint">Escribe al menos 3 letras para buscar</div>
</div>
{:else if error && !results}
<div class="results-message">
<div class="results-error">{error}</div>
</div>
{:else if isLoading && !results}
<div class="results-message">
<div class="results-loading">Buscando...</div>
</div>
{:else if results && results.hits?.length === 0}
<div class="results-message">
<div class="results-empty">Sin resultados para "{searchVal}"</div>
</div>
{/if}
<!-- Contenedor de resultados (siempre montado para evitar flicker) -->
<div
class="results-content results-content-filled"
class:results-content-visible={allHits.length > 0}
>
{#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>
{#if stats.isClassifier}
{#if stats.isEntidad && groupedHits}
<span class="summary-tag summary-tag-entities">{stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
<span class="summary-tag">{groupedHits.length} {groupedHits.length === 1 ? 'categoría' : 'categorías'}</span>
{/if}
{:else}
<span class="summary-tag">en {stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
{#if stats.yearRange}
<span class="summary-tag">{stats.yearRange}</span>
{/if}
{/if}
</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
</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>
<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
{/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}
<!-- 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>
<div class="results-list">
{#if groupedHits}
<!-- Vista agrupada para entidades -->
{#each groupedHits as group, gi}
<div class="result-group">
<div class="result-group-header">
<span class="result-group-name">{group.name}</span>
<span class="result-group-count">{group.hits.length}</span>
<span class="result-group-monto">{formatMonto(group.totalMonto)}</span>
</div>
<div class="result-group-items">
{#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))}
{@const meta = parseMetadatos(hit.document.metadatos)}
{@const isDA = meta.da && meta.entidad_desc_entidad}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
<a href="/entidad/{meta.entidad}" class="result-card result-card-grouped">
<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 isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if}
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
</div>
</div>
</a>
{/each}
</div>
</div>
{/each}
{:else}
<!-- Vista plana para otros tipos -->
{#each sortedHits as hit, i (getHitKey(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"
>
<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-parent">Dependiente 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}
<button class="pill" style="color:{ex.color};background:{ex.bg}" on:click={() => searchVal = ex.label}>{ex.label}</button>
<span class="result-year">{hit.document.gestion}</span>
{/if}
<span class="result-monto">{formatMonto(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}
{/if}
</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>
<div class="area-spacer" class:area-spacer-hidden={allHits.length > 0}></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">
<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>
<span>Explora el portal</span>
<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>
</section>
</section>
</main>
<!-- ═══ PORTAL DIRECTORY ═══ -->
<section class="directory" data-section="datos">
......@@ -905,7 +1255,7 @@
.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}
.gold{color:#F0C14D}
.container{max-width:1060px;margin:0 auto}
/* Reveal */
......@@ -932,6 +1282,7 @@
.nav-logo img{height:100%;width:auto;object-fit:contain}
/* ── HERO ── */
main[data-section="hero"]{height:100vh;background:#1C1C1A;display:flex;flex-direction:column;scroll-snap-align:start;scroll-snap-stop:always;overflow-x:hidden;overflow-y:visible;position:relative}
.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}
......@@ -941,7 +1292,7 @@
.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{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#D0CCC4;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}
......@@ -951,16 +1302,17 @@
.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-wrap{max-width:960px;width:100%;margin:0 auto;position:relative;z-index:9999;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
.search-wrap-with-results{background:rgba(255,255,255,0.04);border-radius:20px;border:1px solid rgba(255,255,255,0.08);padding:6px}
.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-focused{background:rgba(255,255,255,0.06);box-shadow:0 12px 40px rgba(0,0,0,0.4),0 4px 12px rgba(0,0,0,0.2)}
.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:#C9A751;background:rgba(201,167,81,0.12)}
.search-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)}
.search-diario-link:hover{background:rgba(74,139,110,0.22);color:#5AA080}
.search-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}
......@@ -968,9 +1320,11 @@
.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}
.blink-cursor{width:1.5px;height:18px;margin-right:1px;animation:blink 1s step-end infinite;border-radius:1px;flex-shrink:0;background:#C9A751}
.cursor-hidden{opacity:0}
.cursor-diario{background:#4A8B6E}
.cursor-archivo{background:#C9A751}
.has-results{border-radius:14px;box-shadow:none;background:rgba(255,255,255,0.03)}
.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}
......@@ -1003,9 +1357,9 @@
.mobile-meta-bar{display:flex;gap:10px;padding:4px 0}
.mobile-meta-item{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--sans);font-size:13px;font-weight:500;padding:12px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
.mobile-meta-item svg{flex-shrink:0}
.mobile-search-badge{color:#C9A751;background:rgba(201,167,81,0.12)}
.mobile-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)}
.mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(74,139,110,0.22)}
.mobile-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}
......@@ -1041,9 +1395,138 @@
.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{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:100;transition:opacity 0.3s;color:#B8B5AD}
.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}
.scroll-icon-mouse{display:block}
.scroll-icon-touch{display:none}
/* ── NEW SEARCH STYLES ── */
.search-container{width:100%;max-width:900px;margin:0 auto;padding:0 24px;display:flex;flex-direction:column;align-items:center;flex:1;justify-content:center}
.hero-title{font-size:clamp(44px,5.5vw,72px);font-weight:900;line-height:1.02;letter-spacing:-0.035em;color:#F5F0E8;text-align:center;transition:all 0.4s cubic-bezier(0.16,1,0.3,1);margin-bottom:4px}
.hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#D0CCC4;text-align:center;line-height:1.6;transition:all 0.4s cubic-bezier(0.16,1,0.3,1);margin-bottom:32px;margin-top:0}
.hero-collapsed{opacity:0;height:0;margin:0;overflow:hidden;pointer-events:none}
.cursor-hidden{opacity:0}
/* Filters */
.filters-row{display:flex;flex-direction:column;gap:16px;margin-top:20px;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
.filters-hidden{opacity:0;height:0;margin:0;overflow:hidden;pointer-events:none}
.filter-level{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:center}
.filter-level-1{justify-content:center}
.filter-level-2{flex-direction:column;gap:8px}
.filter-level-label{font-family:var(--mono);font-size:11px;letter-spacing:0.08em;color:#9B9890}
/* Segmented Control */
.segmented-control{display:inline-flex;background:rgba(255,255,255,0.06);border-radius:10px;padding:4px;gap:2px}
.segmented-control-secondary{background:rgba(255,255,255,0.04)}
.segment{font-family:var(--sans);font-size:14px;font-weight:500;color:#8B8880;padding:10px 20px;border:none;background:transparent;border-radius:8px;cursor:pointer;transition:all 0.2s}
.segment:hover{color:#B8B5AD}
.segment-active{background:rgba(255,255,255,0.12);color:#F5F0E8}
.segment-secondary{font-size:13px;padding:8px 16px}
/* Checkbox Chips */
.checkbox-group{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}
.checkbox-chip{display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:20px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:all 0.2s;font-family:var(--sans);font-size:13px;color:#8B8880}
.checkbox-chip:hover{background:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.12)}
.checkbox-chip-active{background:rgba(74,139,110,0.15);border-color:rgba(74,139,110,0.4);color:#5AAF8A}
.checkbox-chip-disabled{opacity:0.4;cursor:not-allowed}
.checkbox-chip-disabled:hover{background:rgba(255,255,255,0.04);border-color:rgba(255,255,255,0.08)}
.checkbox-box{width:16px;height:16px;border-radius:4px;border:1.5px solid rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;transition:all 0.2s;flex-shrink:0}
.checkbox-chip-active .checkbox-box{background:#4A8B6E;border-color:#4A8B6E}
.checkbox-label{line-height:1}
.chip-soon{font-family:var(--mono);font-size:9px;letter-spacing:0.05em;color:#6B6860;background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px;margin-left:4px}
/* Results Area */
.results-area{width:100%;margin-top:24px;position:relative;min-height:60px;text-align:center}
.area-spacer{height:100px;transition:height 0.3s}
.area-spacer-hidden{height:0}
/* Examples */
.examples{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:center;transition:all 0.3s}
.examples-hidden{opacity:0;height:0;overflow:hidden;pointer-events:none}
.examples-label{font-family:var(--mono);font-size:11px;letter-spacing:0.08em;color:#9B9890}
.example-chip{font-family:var(--sans);font-size:13px;font-weight:500;padding:8px 14px;border-radius:20px;border:1px solid rgba(255,255,255,0.1);cursor:pointer;transition:all 0.2s}
.example-chip:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}
/* Daily Hint */
.daily-hint{display:inline-flex;align-items:center;gap:8px;margin-top:28px;padding:8px 14px;background:transparent;border:1px solid rgba(74,139,110,0.25);border-radius:20px;transition:all 0.3s;opacity:0;transform:translateY(10px);text-decoration:none;font-size:13px}
.daily-hint-visible{opacity:1;transform:translateY(0)}
.daily-hint:hover{background:rgba(74,139,110,0.08);border-color:rgba(74,139,110,0.4)}
.daily-dot{width:6px;height:6px;border-radius:50%;background:#4A8B6E;animation:pulse 2s ease-in-out infinite;flex-shrink:0}
.daily-text{font-family:var(--sans);font-size:12px;color:#9B9890}
.daily-text strong{color:#4A8B6E}
.daily-hint svg{color:#4A8B6E;opacity:0.6;transition:all 0.2s}
.daily-hint:hover svg{opacity:1;transform:translateX(3px)}
/* Results Messages */
.results-message{text-align:center;padding:20px}
.results-hint,.results-loading{font-family:var(--sans);font-size:14px;color:#6B6860}
.results-error{font-family:var(--sans);font-size:14px;color:#C44B3F}
.results-empty{font-family:var(--sans);font-size:14px;color:#8B8880}
/* Results Content */
.results-content{max-height:0;overflow:hidden;opacity:0;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)}
.results-content-visible{max-height:60vh;overflow-y:auto;opacity:1;background:transparent;border-radius:0;border:none;padding:14px 10px;text-align:left}
/* Results Summary */
.results-summary{background:transparent;border-radius:0;padding:0 0 14px 0;margin-bottom:14px;border-bottom:1px solid rgba(255,255,255,0.08)}
.summary-row{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap}
.summary-left{display:flex;flex-direction:column;gap:8px}
.summary-header{display:flex;align-items:center;gap:8px}
.summary-count{font-family:var(--mono);font-size:24px;font-weight:600;color:#F0C14D}
.summary-header-text{font-family:var(--sans);font-size:14px;color:#A5A29A}
.updating-dot{width:6px;height:6px;border-radius:50%;background:#C9A751;animation:pulse 1s ease-in-out infinite}
.summary-meta{display:flex;gap:8px;flex-wrap:wrap}
.summary-tag{font-family:var(--mono);font-size:11px;letter-spacing:0.03em;padding:4px 10px;border-radius:6px;background:rgba(255,255,255,0.06);color:#8B8880}
.summary-tag-gold{background:rgba(240,193,77,0.18);color:#F0C14D}
.summary-tag-entities{background:rgba(74,139,110,0.12);color:#4A8B6E}
/* Sort Buttons */
.sort-buttons{display:flex;gap:8px}
.sort-btn{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:12px;color:#6B6860;padding:8px 12px;border-radius:8px;background:rgba(255,255,255,0.04);border:none;cursor:pointer;transition:all 0.2s}
.sort-btn:hover{background:rgba(255,255,255,0.08);color:#B8B5AD}
.sort-btn-active{background:rgba(201,167,81,0.12);color:#C9A751}
/* Results List */
.results-list{display:flex;flex-direction:column;gap:0}
/* Result Group (for entidades) */
.result-group{background:transparent;border-radius:0;overflow:hidden;border-bottom:1px solid rgba(255,255,255,0.06);padding-bottom:8px;margin-bottom:8px}
.result-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.result-group-header{display:flex;align-items:center;gap:12px;padding:8px 0;background:transparent}
.result-group-name{font-family:var(--sans);font-size:13px;font-weight:500;color:#F5F0E8;flex:1}
.result-group-count{font-family:var(--mono);font-size:11px;color:#6B6860;background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:4px}
.result-group-monto{font-family:var(--mono);font-size:12px;color:#F0C14D}
.result-group-items{display:flex;flex-direction:column}
/* Result Card */
.result-card{display:flex;align-items:center;gap:12px;padding:12px 14px;background:transparent;border-radius:8px;text-decoration:none;transition:all 0.15s;border-bottom:1px solid rgba(255,255,255,0.06)}
.result-card:last-child{border-bottom:none}
.result-card:hover{background:rgba(255,255,255,0.06)}
.result-card-grouped{border-radius:6px;border-bottom:none;padding:10px 12px;margin-left:8px}
.result-card-grouped:hover{background:rgba(255,255,255,0.03)}
.result-content{flex:1;min-width:0}
.result-text{font-family:var(--sans);font-size:14px;color:#F5F0E8;line-height:1.4}
.result-text :global(mark){background:rgba(240,193,77,0.85);color:#1C1C1A;border-radius:3px;padding:1px 5px;font-weight:600}
.result-meta{display:flex;align-items:center;gap:10px;margin-top:6px;flex-wrap:wrap}
.result-parent,.result-subarea,.result-class-type{font-family:var(--mono);font-size:11px;color:#8B8880}
.result-year{font-family:var(--mono);font-size:12px;color:#8B8880;background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:4px}
.result-monto{font-family:var(--mono);font-size:12px;color:#F0C14D;margin-left:auto}
.result-link-icon{color:#6B6860;opacity:0;transition:all 0.2s;flex-shrink:0}
.result-card:hover .result-link-icon{opacity:1}
/* Load More */
.load-more-container{display:flex;justify-content:center;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.06)}
.load-more-btn{font-family:var(--sans);font-size:14px;color:#8B8880;padding:12px 24px;border-radius:10px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:all 0.2s;display:flex;align-items:center;gap:8px}
.load-more-btn:hover{background:rgba(255,255,255,0.08);color:#F5F0E8}
.load-more-btn:disabled{opacity:0.5;cursor:not-allowed}
.loading-dot{width:6px;height:6px;border-radius:50%;background:#C9A751;animation:pulse 1s ease-in-out infinite}
.search-loading{display:flex;align-items:center;justify-content:center}
/* Nav elements in hero */
main[data-section="hero"] .nav-logo{position:absolute;top:24px;left:24px}
.nav-menu-btn{position:absolute;top:24px;right:24px;background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.2s}
.nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
/* ── DIRECTORY ── */
.directory{background:#f5f5f7;min-height:100vh;display:flex;align-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:48px}
......@@ -1222,7 +1705,7 @@
══════════════════════════════════════════════════════════════════════════ */
@media(max-width:768px){
.page{scroll-snap-type:y proximity}
.landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal}
main[data-section="hero"],.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}
......@@ -1263,6 +1746,36 @@
.pills{gap:6px}
.pill{font-size:11px;padding:4px 10px}
.scroll-hint{bottom:20px}
.scroll-icon-mouse{display:none}
.scroll-icon-touch{display:block}
/* New search responsive */
.search-container{padding:0 16px}
.hero-title{font-size:clamp(32px,7vw,48px);margin-bottom:4px}
.hero-sub{font-size:14px;margin-bottom:24px}
.filters-row{gap:12px;margin-top:16px}
.filter-level{flex-direction:column;gap:8px}
.segmented-control{width:100%;justify-content:center}
.segment{flex:1;text-align:center;padding:10px 12px;font-size:13px}
.checkbox-group{justify-content:flex-start}
.checkbox-chip{font-size:12px;padding:6px 12px}
.examples{flex-direction:row;gap:8px}
.examples-label{display:none}
.example-chip{font-size:12px;padding:6px 12px}
.daily-hint{padding:10px 14px;gap:8px}
.daily-text{font-size:13px}
.results-summary{padding:12px 14px}
.summary-row{flex-direction:column;gap:12px}
.summary-count{font-size:20px}
.summary-meta{flex-wrap:wrap}
.summary-tag{font-size:10px;padding:3px 8px}
.sort-buttons{width:100%;justify-content:flex-end}
.sort-btn{font-size:11px;padding:6px 10px}
.result-card{padding:12px 14px}
.result-text{font-size:13px}
.result-monto{font-size:11px}
.result-year{font-size:11px;padding:2px 6px}
.load-more-btn{font-size:13px;padding:10px 20px}
/* Directory */
.directory{padding:48px 24px}
......@@ -1351,6 +1864,34 @@
.pill{font-size:10px;padding:4px 8px}
.scroll-hint{display:none}
/* New search 480px */
.search-container{padding:0 12px}
.hero-title{font-size:clamp(28px,8vw,36px)}
.hero-sub{font-size:13px;margin-bottom:20px}
main[data-section="hero"] .nav-logo{top:16px;left:16px;height:60px}
.nav-menu-btn{top:16px;right:16px;width:40px;height:40px}
.segmented-control{padding:3px}
.segment{padding:8px 10px;font-size:12px}
.segment-secondary{font-size:11px;padding:6px 8px}
.filter-level-label{font-size:10px}
.checkbox-chip{font-size:11px;padding:5px 10px;gap:6px}
.checkbox-box{width:14px;height:14px}
.example-chip{font-size:11px;padding:5px 10px}
.daily-hint{padding:8px 12px;border-radius:10px}
.daily-dot{width:6px;height:6px}
.daily-text{font-size:12px}
.results-summary{padding:10px 12px;border-radius:10px}
.summary-count{font-size:18px}
.summary-header-text{font-size:12px}
.result-group-header{padding:10px 12px}
.result-group-name{font-size:12px}
.result-card{padding:10px 12px;border-radius:8px}
.result-text{font-size:12px}
.result-meta{gap:6px;margin-top:4px}
.result-parent,.result-subarea,.result-class-type{font-size:10px}
.result-monto{font-size:10px}
.load-more-btn{font-size:12px;padding:8px 16px;border-radius:8px}
/* Directory */
.directory{padding:40px 16px}
.directory-inner{gap:24px}
......
// Proxy para evitar CORS
const API_BASE = 'http://34.66.240.236/api/proyecto/search';
export async function GET({ url }) {
const q = url.searchParams.get('q') || '';
const is_class = url.searchParams.get('is_class') || 'false';
const mode = url.searchParams.get('mode') || 'gastos'; // 'gastos' | 'ingresos'
const class_ = url.searchParams.get('class_') || ''; // comma-separated: entidad, objeto, finfun, acteco
const page = url.searchParams.get('page') || '1'; // Paginación
const per_page = url.searchParams.get('per_page') || ''; // Resultados por página
// Build URL with all parameters
const apiUrl = new URL(API_BASE);
apiUrl.searchParams.set('q', q);
apiUrl.searchParams.set('is_class', is_class);
apiUrl.searchParams.set('page', page);
if (per_page) apiUrl.searchParams.set('per_page', per_page);
if (mode) apiUrl.searchParams.set('mode', mode);
if (class_) apiUrl.searchParams.set('class_', class_);
try {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
console.error('Proxy error:', err);
return new Response(JSON.stringify({ error: 'Failed to fetch from API' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
me pare<script>
<script>
import { supabase } from '$lib/supabase';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
......
......@@ -87,16 +87,20 @@
let treemapHeight = $state(500);
let treemapContainer = $state(null);
let hoveredNode = $state(null);
let pinnedNode = $state(null); // Nodo fijado para mobile (primer tap)
let tooltipHovered = $state(false); // Para mantener tooltip visible al pasar mouse sobre él
let isMobile = $state(false); // Detectar si es dispositivo táctil
let showTreemapHelp = $state(false);
let mapaSidebarOpen = $state(false); // Para abrir sidebar en móvil
let mapaSidebarCollapsed = $state(false); // Para colapsar sidebar en desktop
let sidebarYearDropdownOpen = $state(false); // Dropdown de año en sidebar
// Selector de nivel para vista aplanada
let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida'
let treemapViewLevel = $state('partida'); // 'jerarquico' | 'grupo' | 'subgrupo' | 'partida' | 'subpartida'
let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias)
const NIVEL_OPTIONS = [
{ value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' },
{ value: 'grupo', label: 'Grupos', desc: '8-9 categorías' },
{ value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' },
{ value: 'partida', label: 'Partidas', desc: '~100 categorías' },
{ value: 'subpartida', label: 'Subpartidas', desc: '~300 categorías' }
......@@ -409,7 +413,7 @@
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(4)
.paddingTop(22) // Espacio para el título del grupo
.paddingTop(treemapViewLevel === 'grupo' ? 4 : 22) // Solo espacio extra para título si no estamos en nivel grupo
.paddingInner(2)
.round(true);
......@@ -432,8 +436,8 @@
value: grupoNode.value
});
// Agregar los items del grupo
if (grupoNode.children) {
// Agregar los items del grupo (solo si no estamos en nivel grupo)
if (grupoNode.children && treemapViewLevel !== 'grupo') {
grupoNode.children.forEach(itemNode => {
nodes.push({
type: 'item',
......@@ -543,6 +547,47 @@
calculateTreemapLayout();
}
// Navegar a vista de distribución por entidades
function navigateToEntityDistribution(node) {
// Obtener el código del objeto (puede ser grupo, subgrupo, partida o subpartida)
const codigo = node.id || node.data?.objeto;
if (codigo) {
// Navegar a la ruta de distribución por entidades
goto(`/objeto/${codigo}/entidades?gestion=${selectedYear}`);
}
}
// Handler de click para nodos en vista plana (no jerárquica)
function handleFlatNodeClick(node, e) {
e.stopPropagation(); // Evitar que cierre el tooltip inmediatamente
if (treemapViewLevel === 'jerarquico') return; // No aplica en modo jerárquico
if (isMobile) {
// Mobile: primer tap fija el tooltip, segundo tap (en botón) navega
if (pinnedNode === node) {
// Ya está fijado, no hacer nada (el botón maneja la navegación)
return;
}
// Si hay otro nodo fijado, cambiar a este
if (pinnedNode && pinnedNode !== node) {
pinnedNode = node;
hoveredNode = node;
return;
}
// Fijar el tooltip en este nodo
pinnedNode = node;
hoveredNode = node;
} else {
// Desktop: click directo navega
navigateToEntityDistribution(node);
}
}
// Cerrar tooltip fijado
function closePinnedTooltip() {
pinnedNode = null;
}
// Reactivo: recargar datos cuando cambia el año o nivel
// Usar variable derivada para evitar loops en effects
let treemapLoadTrigger = $derived(`${selectedYear}-${selectedEntity?.entidad ?? 0}-${treemapViewLevel}-${viewMode}-${showConsolidado}`);
......@@ -720,6 +765,9 @@
});
onMount(async () => {
// Detectar dispositivo táctil
isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// Forzar recálculo de layout para navegación cliente
// Esto evita problemas de renderizado cuando se navega desde otra página
requestAnimationFrame(() => {
......@@ -2222,6 +2270,7 @@
width={treemapWidth}
height={treemapHeight}
class="block"
onclick={() => { if (pinnedNode) { pinnedNode = null; hoveredNode = null; } }}
>
{#if treemapViewLevel === 'jerarquico'}
<!-- MODO JERÁRQUICO: con drill-down -->
......@@ -2314,7 +2363,56 @@
{@const textColorSecondary = getTextColorSecondary(grupoId)}
{#if node.type === 'grupo'}
<!-- Contenedor de grupo con título -->
{#if treemapViewLevel === 'grupo'}
<!-- Grupo como item principal (nivel grupo) -->
{@const pct = (node.value / flatTotalValue) * 100}
{@const area = width * height}
{@const scale = Math.sqrt(area) / 12}
{@const pctSize = Math.min(20, Math.max(9, scale * 1.5))}
{@const nameSize = Math.min(13, Math.max(8, scale * 0.95))}
{@const valueSize = Math.min(11, Math.max(7, scale * 0.75))}
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }}
onclick={(e) => handleFlatNodeClick(node, e)}
style="cursor: pointer;"
>
<rect
width={width}
height={height}
fill={getNodeColor(grupoId, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
stroke-width={isHovered ? 2 : 0}
rx="2"
/>
{#if width > 35 && height > 22}
<foreignObject x="4" y="3" width={width - 8} height={height - 6}>
<div class="flex flex-col gap-0 overflow-hidden" style="font-family: var(--font-sans);">
<div class="font-semibold leading-none opacity-75" style="font-size: {pctSize}px; color: {textColor};">
{pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
</div>
{#if height > 40 && width > 50}
<div class="leading-tight mt-0.5 opacity-85" style="font-size: {nameSize}px; color: {textColor};">
{width > 90 ? node.name : node.name.slice(0, Math.floor(width / 6)) + (node.name.length > Math.floor(width / 6) ? '…' : '')}
</div>
{/if}
{#if height > 55 && width > 55}
<div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
</foreignObject>
{/if}
</g>
{:else}
<!-- Contenedor de grupo con título (niveles subgrupo, partida, subpartida) -->
<g class="treemap-node" transform="translate({node.x0}, {node.y0})">
<!-- Fondo del grupo (sutil) -->
<rect
......@@ -2348,6 +2446,7 @@
</text>
{/if}
</g>
{/if}
{:else}
<!-- Item individual -->
{@const pct = (node.value / flatTotalValue) * 100}
......@@ -2359,8 +2458,9 @@
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }}
onclick={(e) => handleFlatNodeClick(node, e)}
style="cursor: pointer;"
>
<rect
......@@ -2401,15 +2501,28 @@
</svg>
<!-- Tooltip flotante (frosted glass) -->
{#if hoveredNode && hoveredNode.type !== 'grupo'}
{#if hoveredNode && (hoveredNode.type !== 'grupo' || treemapViewLevel === 'grupo')}
{@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
{@const isPinned = pinnedNode === hoveredNode}
<div
class="treemap-tooltip"
class:pinned={isPinned}
class:has-cta={treemapViewLevel !== 'jerarquico'}
style="
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 140)}px;
"
onclick={(e) => e.stopPropagation()}
onmouseenter={() => { if (!isMobile && treemapViewLevel !== 'jerarquico') tooltipHovered = true; }}
onmouseleave={() => { tooltipHovered = false; hoveredNode = null; }}
>
{#if isPinned}
<button
class="tooltip-close"
onclick={() => { pinnedNode = null; hoveredNode = null; }}
aria-label="Cerrar"
>&times;</button>
{/if}
{#if treemapViewLevel !== 'jerarquico'}
<!-- Vista aplanada: mostrar contexto del grupo -->
<div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
......@@ -2419,6 +2532,15 @@
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total
</div>
<!-- CTA para ver distribución por entidades -->
<div class="tooltip-cta">
<button
class="tooltip-cta-btn"
onclick={() => navigateToEntityDistribution(hoveredNode)}
>
¿Quién gasta más aquí? →
</button>
</div>
{:else}
<!-- Vista jerárquica -->
<div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div>
......@@ -4155,6 +4277,11 @@
font-family: var(--font-sans);
}
.treemap-tooltip.pinned,
.treemap-tooltip.has-cta {
pointer-events: auto;
}
:global(html.dark) .treemap-tooltip {
background: rgba(30, 30, 30, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
......@@ -4162,6 +4289,60 @@
color: #f5f5f5;
}
/* Tooltip close button (mobile) */
.tooltip-close {
position: absolute;
top: 4px;
right: 6px;
width: 24px;
height: 24px;
background: transparent;
border: none;
color: inherit;
opacity: 0.5;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tooltip-close:hover {
opacity: 1;
}
/* Tooltip CTA */
.tooltip-cta {
margin-top: 0.75rem;
padding-top: 0.625rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
:global(html.dark) .tooltip-cta {
border-top-color: rgba(255, 255, 255, 0.1);
}
.tooltip-cta-btn {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--theme-accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
text-align: left;
}
.tooltip-cta-btn:hover {
background: color-mix(in srgb, var(--theme-accent) 85%, black);
transform: translateX(2px);
}
.tooltip-cta-btn:active {
transform: scale(0.98);
}
/* Transiciones suaves para el treemap */
:global(.treemap-node) {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
......
<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>
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import * as d3 from 'd3';
// Params y query
let codigo = $derived($page.params.codigo);
let gestion = $derived($page.url.searchParams.get('gestion') || new Date().getFullYear().toString());
// Estado
let loading = $state(true);
let objetoInfo = $state(null); // Info del objeto (desc_objeto, nivel)
let entidadesData = $state([]);
let availableYears = $state([]);
let selectedYear = $state(null);
// Treemap
let treemapContainer = $state(null);
let treemapWidth = $state(0);
let treemapHeight = $state(500);
let treemapNodes = $state([]);
let treemapRoot = $state(null);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
// Población para per cápita
const POBLACION_BOLIVIA = 12006031;
// Formatters
function formatMoney(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
return `Bs ${value.toFixed(0)}`;
}
function formatMoneyCompact(value) {
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`;
return value.toFixed(0);
}
function formatPerCapita(value) {
const perCapita = value / POBLACION_BOLIVIA;
return `Bs ${perCapita.toFixed(0)}/hab`;
}
// Colores para entidades (gradiente basado en ranking)
function getEntityColor(index, total, opacity = 0.85) {
// Gradiente de más oscuro (top) a más claro (bottom)
const hue = 210; // Azul
const saturation = 60 + (index / total) * 20;
const lightness = 35 + (index / total) * 30;
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`;
}
function getTextColor(index, total) {
return index < total * 0.6 ? '#ffffff' : '#1a1a1a';
}
// Cargar años disponibles
async function loadAvailableYears() {
const { data, error } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('gestion')
.eq('objeto', parseInt(codigo))
.gt('devengado', 0);
if (!error && data) {
const years = [...new Set(data.map(d => d.gestion))].sort((a, b) => b - a);
availableYears = years;
// Usar año de query param o el más reciente
const yearParam = parseInt(gestion);
selectedYear = years.includes(yearParam) ? yearParam : years[0];
}
}
// Cargar datos de entidades para el objeto
async function loadEntityData() {
if (!selectedYear || !codigo) return;
loading = true;
try {
// Obtener info del objeto
const { data: infoData } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('desc_objeto, nivel')
.eq('objeto', parseInt(codigo))
.eq('gestion', selectedYear)
.limit(1)
.single();
if (infoData) {
objetoInfo = infoData;
}
// Obtener distribución por entidades (excluyendo entidad 0=total y 99=otros)
const { data, error } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('entidad, desc_entidad, devengado')
.eq('objeto', parseInt(codigo))
.eq('gestion', selectedYear)
.neq('entidad', 0)
.neq('entidad', 99)
.gt('devengado', 0)
.order('devengado', { ascending: false });
if (error) {
console.error('Error loading entity data:', error);
return;
}
entidadesData = data || [];
totalDevengado = entidadesData.reduce((sum, d) => sum + (d.devengado || 0), 0);
buildTreemap();
} catch (err) {
console.error('Error:', err);
} finally {
loading = false;
}
}
// Construir treemap
function buildTreemap() {
if (!entidadesData.length || treemapWidth <= 0) return;
const hierarchy = d3.hierarchy({
name: 'root',
children: entidadesData.map((d, i) => ({
id: d.entidad,
name: d.desc_entidad || `Entidad ${d.entidad}`,
value: d.devengado,
index: i
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.padding(2)
.round(true);
treemap(hierarchy);
treemapRoot = hierarchy;
treemapNodes = hierarchy.children || [];
}
// Resize observer
function setupResizeObserver() {
if (!treemapContainer) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
treemapWidth = entry.contentRect.width;
buildTreemap();
}
});
observer.observe(treemapContainer);
return () => observer.disconnect();
}
// Navegación
function goBack() {
goto(`/clasificadores/objeto-gasto?modo=mapa`);
}
function changeYear(year) {
selectedYear = year;
goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
}
// Efectos
$effect(() => {
if (codigo) {
loadAvailableYears();
}
});
$effect(() => {
if (selectedYear && codigo) {
loadEntityData();
}
});
$effect(() => {
if (treemapContainer) {
return setupResizeObserver();
}
});
$effect(() => {
if (entidadesData.length && treemapWidth > 0) {
buildTreemap();
}
});
onMount(() => {
if (treemapContainer) {
treemapWidth = treemapContainer.clientWidth;
}
});
</script>
<svelte:head>
<title>{objetoInfo?.desc_objeto || `Objeto ${codigo}`} - Distribución por Entidades</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="page-header">
<button class="back-btn" onclick={goBack}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver al mapa
</button>
<div class="header-content">
<div class="header-badge">{objetoInfo?.nivel || 'objeto'}</div>
<h1 class="header-title">
<span class="header-code">{codigo}</span>
<span class="header-separator">·</span>
<span class="header-name">{objetoInfo?.desc_objeto || 'Cargando...'}</span>
</h1>
<p class="header-subtitle">¿Qué entidades gastan más en esto?</p>
</div>
<!-- Selector de año -->
<div class="year-selector">
<label>Gestión:</label>
<select bind:value={selectedYear} onchange={(e) => changeYear(parseInt(e.target.value))}>
{#each availableYears as year}
<option value={year}>{year}</option>
{/each}
</select>
</div>
</header>
<!-- Stats resumen -->
{#if !loading && entidadesData.length > 0}
<div class="stats-bar">
<div class="stat">
<span class="stat-value">{entidadesData.length}</span>
<span class="stat-label">entidades</span>
</div>
<div class="stat">
<span class="stat-value">{formatMoney(totalDevengado)}</span>
<span class="stat-label">total ejecutado</span>
</div>
<div class="stat">
<span class="stat-value">{formatPerCapita(totalDevengado)}</span>
<span class="stat-label">per cápita</span>
</div>
</div>
{/if}
<!-- Treemap -->
<div class="treemap-section">
<div
bind:this={treemapContainer}
class="treemap-container"
style="height: {treemapHeight}px;"
>
{#if loading}
<div class="loading-state">
<div class="loader"></div>
<p>Cargando distribución...</p>
</div>
{:else if treemapNodes.length > 0}
<svg width={treemapWidth} height={treemapHeight} class="block">
{#each treemapNodes as node, i}
{@const width = node.x1 - node.x0}
{@const height = node.y1 - node.y0}
{@const isHovered = hoveredNode === node}
{@const pct = (node.value / totalDevengado) * 100}
{@const area = width * height}
{@const scale = Math.sqrt(area) / 12}
{@const pctSize = Math.min(22, Math.max(10, scale * 1.6))}
{@const nameSize = Math.min(14, Math.max(8, scale * 1.0))}
{@const valueSize = Math.min(12, Math.max(7, scale * 0.8))}
{@const textColor = getTextColor(i, treemapNodes.length)}
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
>
<rect
width={width}
height={height}
fill={getEntityColor(i, treemapNodes.length, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
stroke-width={isHovered ? 2 : 0}
rx="3"
/>
{#if width > 40 && height > 25}
<foreignObject x="4" y="4" width={width - 8} height={height - 8}>
<div class="node-content" style="color: {textColor};">
<div class="node-pct" style="font-size: {pctSize}px;">
{pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
</div>
{#if height > 45 && width > 60}
<div class="node-name" style="font-size: {nameSize}px;">
{width > 120 ? node.data.name : node.data.name.slice(0, Math.floor(width / 7)) + (node.data.name.length > Math.floor(width / 7) ? '…' : '')}
</div>
{/if}
{#if height > 65 && width > 70}
<div class="node-value" style="font-size: {valueSize}px;">
{formatMoneyCompact(node.value)}
</div>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/each}
</svg>
<!-- Tooltip -->
{#if hoveredNode}
{@const pct = (hoveredNode.value / totalDevengado) * 100}
<div
class="treemap-tooltip"
style="
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 280)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
"
>
<div class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</div>
<div class="tooltip-name">{hoveredNode.data.name}</div>
<div class="tooltip-value">{formatMoney(hoveredNode.value)}</div>
<div class="tooltip-pct">{pct.toFixed(1)}% del total · {formatPerCapita(hoveredNode.value)}</div>
</div>
{/if}
{:else}
<div class="empty-state">
<p>No hay datos de entidades para este objeto en {selectedYear}</p>
</div>
{/if}
</div>
</div>
<!-- Ranking list -->
{#if !loading && entidadesData.length > 0}
<div class="ranking-section">
<h2 class="ranking-title">Ranking de entidades</h2>
<div class="ranking-list">
{#each entidadesData.slice(0, 20) as entity, i}
{@const pct = (entity.devengado / totalDevengado) * 100}
<div class="ranking-item">
<span class="ranking-pos">#{i + 1}</span>
<div class="ranking-bar-container">
<div class="ranking-bar" style="width: {pct}%; background: {getEntityColor(i, entidadesData.length)};"></div>
<div class="ranking-info">
<span class="ranking-name">{entity.desc_entidad || `Entidad ${entity.entidad}`}</span>
<span class="ranking-value">{formatMoney(entity.devengado)} <span class="ranking-pct">({pct.toFixed(1)}%)</span></span>
</div>
</div>
</div>
{/each}
{#if entidadesData.length > 20}
<p class="ranking-more">+ {entidadesData.length - 20} entidades más</p>
{/if}
</div>
</div>
{/if}
</div>
<style>
.page-container {
min-height: 100vh;
background: var(--theme-fondo);
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.page-header {
margin-bottom: 1.5rem;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
color: var(--theme-texto);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
margin-bottom: 1rem;
}
.back-btn:hover {
background: var(--theme-fill);
border-color: var(--theme-accent);
color: var(--theme-accent);
}
.header-content {
margin-bottom: 1rem;
}
.header-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--theme-accent);
color: white;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.header-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem 0;
line-height: 1.3;
}
.header-code {
font-family: var(--font-mono);
opacity: 0.7;
}
.header-separator {
opacity: 0.4;
margin: 0 0.25rem;
}
.header-subtitle {
font-size: 1rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
}
.year-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.year-selector label {
font-size: 0.875rem;
color: var(--theme-texto);
}
.year-selector select {
padding: 0.5rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: var(--theme-titulo);
font-size: 0.875rem;
cursor: pointer;
}
/* Stats bar */
.stats-bar {
display: flex;
gap: 2rem;
padding: 1rem 1.5rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 12px;
margin-bottom: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--theme-titulo);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Treemap */
.treemap-section {
margin-bottom: 2rem;
}
.treemap-container {
position: relative;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 12px;
overflow: hidden;
}
.loading-state,
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.7;
}
.loader {
width: 40px;
height: 40px;
border: 3px solid var(--theme-borde);
border-top-color: var(--theme-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
:global(.treemap-node) {
transition: opacity 0.15s ease;
}
:global(.treemap-node rect) {
transition: fill 0.15s ease;
}
.node-content {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
font-family: var(--font-sans);
}
.node-pct {
font-weight: 600;
line-height: 1;
opacity: 0.9;
}
.node-name {
line-height: 1.2;
margin-top: 2px;
opacity: 0.85;
}
.node-value {
font-variant-numeric: tabular-nums;
opacity: 0.7;
margin-top: 2px;
}
/* Tooltip */
.treemap-tooltip {
position: absolute;
pointer-events: none;
padding: 0.75rem 1rem;
border-radius: 10px;
z-index: 10;
max-width: 280px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
font-family: var(--font-sans);
}
:global(html.dark) .treemap-tooltip {
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
color: #f5f5f5;
}
.tooltip-rank {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
margin-bottom: 0.25rem;
}
.tooltip-name {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.3;
}
.tooltip-value {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.5rem;
font-variant-numeric: tabular-nums;
}
.tooltip-pct {
font-size: 0.75rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* Ranking list */
.ranking-section {
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 12px;
padding: 1.5rem;
}
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 1rem 0;
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ranking-item {
display: grid;
grid-template-columns: 2.5rem 1fr;
gap: 0.75rem;
align-items: center;
}
.ranking-pos {
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-texto);
opacity: 0.5;
font-variant-numeric: tabular-nums;
}
.ranking-bar-container {
position: relative;
height: 32px;
background: var(--theme-fill);
border-radius: 4px;
overflow: hidden;
}
.ranking-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.ranking-info {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
.ranking-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
}
.ranking-value {
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-titulo);
font-variant-numeric: tabular-nums;
}
.ranking-pct {
opacity: 0.6;
font-weight: 400;
}
.ranking-more {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
text-align: center;
margin: 0.5rem 0 0 0;
}
/* Responsive */
@media (max-width: 640px) {
.page-container {
padding: 1rem;
}
.header-title {
font-size: 1.25rem;
}
.stats-bar {
flex-wrap: wrap;
gap: 1rem;
}
.stat-value {
font-size: 1.1rem;
}
.ranking-item {
grid-template-columns: 2rem 1fr;
}
.ranking-name {
max-width: 50%;
}
}
</style>
<script>
import { onMount } from 'svelte';
import * as d3 from 'd3';
import * as Plot from '@observablehq/plot';
// Entidad de ejemplo
const entidad = {
codigo: 139,
nombre: 'Universidad Mayor de San Andrés',
sigla: 'UMSA',
sector: 'Universidades Públicas'
};
// Estado
let streamData = $state([]);
let loading = $state(true);
let container = $state(null);
let width = $state(0);
let height = $state(500);
let tooltip = $state({ show: false, x: 0, y: 0, data: null });
// Drilldown
let currentLevel = $state('tipo'); // tipo -> clase -> cuenta -> subcuenta
let breadcrumb = $state([{ level: 'tipo', codigo: null, descripcion: 'Todos los ingresos' }]);
let currentParent = $state(null);
// Colores para las categorías
const colorScale = d3.scaleOrdinal()
.range([
'#4A8B6E', '#C9A751', '#8B6E9B', '#6B9FD4', '#D4846B',
'#5B8FA8', '#A8845B', '#7B9B6B', '#9B7B8B', '#6B8B9B',
'#B89B6B', '#6B7B9B', '#9B8B6B', '#7B8B7B', '#8B7B6B',
'#6B9B8B', '#9B6B7B', '#7B6B9B', '#8B9B6B', '#6B8B7B'
]);
// Cargar datos
async function loadData() {
loading = true;
try {
const response = await fetch('/stream1.csv');
const text = await response.text();
const parsed = d3.csvParse(text, d => ({
entidad: +d.entidad,
año: +d.año,
nivel: d.nivel,
codigo: +d.codigo,
descripcion: d.descripcion,
codigo_padre: d.codigo_padre ? +d.codigo_padre : null,
desc_padre: d.desc_padre || null,
devengado: +d.devengado
}));
let gestionSeleccionada = $state(2024);
const gestiones = [2020, 2021, 2022, 2023, 2024, 2025];
// Filtrar solo entidad 0 (Todo el Estado)
streamData = parsed.filter(d => d.entidad === 0);
console.log('Stream data loaded:', streamData.length, 'rows');
} catch (err) {
console.error('Error loading data:', err);
}
loading = false;
}
// Datos mock - Historia temporal (20 años)
const historiaIngresos = Array.from({ length: 20 }, (_, i) => ({
gestion: 2005 + i,
monto: 800000000 + Math.random() * 400000000 + i * 50000000
}));
// Obtener datos filtrados para el nivel actual
function getFilteredData() {
let filtered = streamData.filter(d => d.nivel === currentLevel);
const historiaGastos = Array.from({ length: 20 }, (_, i) => ({
gestion: 2005 + i,
monto: 750000000 + Math.random() * 350000000 + i * 45000000
}));
if (currentParent !== null) {
filtered = filtered.filter(d => d.codigo_padre === currentParent);
}
// Datos mock - Composición del gasto
const gastoObjetos = [
{ codigo: '10000', nombre: 'Servicios Personales', monto: 850000000, padre: 'Gasto Corriente' },
{ codigo: '20000', nombre: 'Servicios No Personales', monto: 180000000, padre: 'Gasto Corriente' },
{ codigo: '30000', nombre: 'Materiales y Suministros', monto: 120000000, padre: 'Gasto Corriente' },
{ codigo: '40000', nombre: 'Activos Reales', monto: 95000000, padre: 'Inversión' },
{ codigo: '70000', nombre: 'Transferencias', monto: 45000000, padre: 'Transferencias' },
{ codigo: '80000', nombre: 'Impuestos y Otros', monto: 25000000, padre: 'Otros' }
];
const gastoFinalidad = [
{ codigo: '22', nombre: 'Educación Superior', monto: 920000000, padre: 'Educación' },
{ codigo: '23', nombre: 'Investigación', monto: 180000000, padre: 'Educación' },
{ codigo: '14', nombre: 'Administración', monto: 150000000, padre: 'Servicios Generales' },
{ codigo: '31', nombre: 'Salud', monto: 45000000, padre: 'Servicios Sociales' },
{ codigo: '42', nombre: 'Extensión', monto: 20000000, padre: 'Cultura' }
];
const gastoSectores = [
{ codigo: 'EDU', nombre: 'Educación', monto: 1050000000, padre: 'Social' },
{ codigo: 'ADM', nombre: 'Administración Pública', monto: 180000000, padre: 'Gubernamental' },
{ codigo: 'SAL', nombre: 'Salud', monto: 55000000, padre: 'Social' },
{ codigo: 'CUL', nombre: 'Cultura y Deporte', monto: 30000000, padre: 'Social' }
];
// Datos mock - Origen del dinero
const ingresoRubros = [
{ codigo: '1200', nombre: 'Transferencias TGN', monto: 650000000, padre: 'Transferencias' },
{ codigo: '1400', nombre: 'Venta de Servicios', monto: 280000000, padre: 'Ingresos Propios' },
{ codigo: '1100', nombre: 'Matrículas y Aranceles', monto: 150000000, padre: 'Ingresos Propios' },
{ codigo: '1300', nombre: 'Regalías e IDH', monto: 95000000, padre: 'Coparticipación' },
{ codigo: '1900', nombre: 'Otros Ingresos', monto: 40000000, padre: 'Otros' }
];
const ingresoOrganismos = [
{ codigo: 'TGN', nombre: 'Tesoro General de la Nación', monto: 720000000, padre: 'Gobierno Central' },
{ codigo: 'PROP', nombre: 'Recursos Propios', monto: 350000000, padre: 'Autogestión' },
{ codigo: 'IDH', nombre: 'Impuesto Directo a Hidrocarburos', monto: 95000000, padre: 'Coparticipación' },
{ codigo: 'COOP', nombre: 'Cooperación Internacional', monto: 50000000, padre: 'Externo' }
];
// Ranking mock
const ranking = {
gastos: { posicion: 23, total: 647 },
ingresos: { posicion: 28, total: 647 }
};
return filtered;
// Contenedores para barras D3
let barContainers = $state({});
let hoverData = $state({});
// Contenedores para gráficos de historia
let chartIngresosContainer = $state(null);
let chartGastosContainer = $state(null);
// Clasificadores de gasto
const clasificadoresGasto = [
{ key: 'objetos', label: 'Objetos de gasto', data: gastoObjetos },
{ key: 'finalidad', label: 'Finalidad y función', data: gastoFinalidad },
{ key: 'sectores', label: 'Sectores económicos', data: gastoSectores }
];
// Clasificadores de ingreso
const clasificadoresIngreso = [
{ key: 'rubros', label: 'Rubros de ingreso', data: ingresoRubros },
{ key: 'organismos', label: 'Organismos financiadores', data: ingresoOrganismos }
];
// 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'
};
}
// Preparar datos para el streamgraph
function prepareStreamData() {
const filtered = getFilteredData();
if (!filtered.length) return { years: [], series: [], keys: [] };
// Agrupar por año
const byYear = d3.group(filtered, d => d.año);
const years = Array.from(byYear.keys()).sort((a, b) => a - b);
// Obtener todas las categorías únicas
const categories = [...new Set(filtered.map(d => d.codigo))];
// Renderizar barras D3
function renderizarBarras(key, data) {
const container = barContainers[key];
if (!container) return;
// Crear estructura para stack
const stackData = years.map(year => {
const yearData = byYear.get(year) || [];
const row = { año: year };
categories.forEach(cat => {
const item = yearData.find(d => d.codigo === cat);
row[cat] = item ? item.devengado : 0;
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;
});
return row;
});
// Crear el stack
const stack = d3.stack()
.keys(categories)
.offset(d3.stackOffsetWiggle)
.order(d3.stackOrderInsideOut);
const series = stack(stackData);
// Agregar descripciones a las series
series.forEach(s => {
const item = filtered.find(d => d.codigo === +s.key);
s.descripcion = item ? item.descripcion : s.key;
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 };
});
return { years, series, keys: categories, stackData };
}
// Formatear moneda
function formatMoney(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}B`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
return `Bs ${value.toFixed(0)}`;
}
// Manejar clic para drilldown
function handleClick(codigo, descripcion) {
const levels = ['tipo', 'clase', 'cuenta', 'subcuenta'];
const currentIndex = levels.indexOf(currentLevel);
if (currentIndex < levels.length - 1) {
const nextLevel = levels[currentIndex + 1];
// Verificar si hay datos en el siguiente nivel
const hasChildren = streamData.some(
d => d.nivel === nextLevel && d.codigo_padre === codigo
);
if (hasChildren) {
currentParent = codigo;
currentLevel = nextLevel;
breadcrumb = [...breadcrumb, { level: nextLevel, codigo, descripcion }];
}
}
}
// Navegar con breadcrumb
function navigateTo(index) {
if (index === 0) {
currentLevel = 'tipo';
currentParent = null;
breadcrumb = [{ level: 'tipo', codigo: null, descripcion: 'Todos los ingresos' }];
} else {
const target = breadcrumb[index];
currentLevel = target.level;
currentParent = index > 0 ? breadcrumb[index - 1].codigo : null;
// Si navegamos a un nivel intermedio, necesitamos el padre correcto
if (index > 0) {
currentParent = breadcrumb[index].codigo;
// Buscar el nivel siguiente
const levels = ['tipo', 'clase', 'cuenta', 'subcuenta'];
const nextLevelIndex = levels.indexOf(target.level) + 1;
if (nextLevelIndex < levels.length) {
currentLevel = levels[nextLevelIndex];
}
}
breadcrumb = breadcrumb.slice(0, index + 1);
// 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 (chartIngresosContainer) {
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: 'monto', fill: '#4A8B6E', fillOpacity: 0.3 }),
Plot.lineY(historiaIngresos, { x: 'gestion', y: 'monto', stroke: '#4A8B6E', strokeWidth: 2 }),
Plot.dot(historiaIngresos, { x: 'gestion', y: 'monto', fill: '#4A8B6E', r: 3 })
]
});
chartIngresosContainer.appendChild(plot);
}
if (chartGastosContainer) {
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: 'monto', fill: '#C9A751', fillOpacity: 0.3 }),
Plot.lineY(historiaGastos, { x: 'gestion', y: 'monto', stroke: '#C9A751', strokeWidth: 2 }),
Plot.dot(historiaGastos, { x: 'gestion', y: 'monto', fill: '#C9A751', r: 3 })
]
});
chartGastosContainer.appendChild(plot);
}
}
// Efecto para observar tamaño
// Efectos
$effect(() => {
if (!container) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
width = entry.contentRect.width;
}
setTimeout(() => {
clasificadoresGasto.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
clasificadoresIngreso.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
}, 100);
});
observer.observe(container);
return () => observer.disconnect();
$effect(() => {
if (chartIngresosContainer && chartGastosContainer) {
setTimeout(renderizarHistoria, 150);
}
});
onMount(() => {
loadData();
// Observer para tema
const observer = new MutationObserver(() => {
clasificadoresGasto.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
clasificadoresIngreso.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Datos preparados reactivos
let prepared = $derived(prepareStreamData());
// Escalas reactivas
let xScale = $derived(
d3.scaleLinear()
.domain(d3.extent(prepared.years))
.range([60, width - 20])
);
let yExtent = $derived(() => {
if (!prepared.series.length) return [0, 0];
const allValues = prepared.series.flatMap(s => s.flatMap(d => [d[0], d[1]]));
return d3.extent(allValues);
// Resize observer para charts
const resizeObserver = new ResizeObserver(() => {
renderizarHistoria();
});
if (chartIngresosContainer) resizeObserver.observe(chartIngresosContainer);
if (chartGastosContainer) resizeObserver.observe(chartGastosContainer);
let yScale = $derived(
d3.scaleLinear()
.domain(yExtent())
.range([height - 40, 40])
);
// Generador de área
let areaGenerator = $derived(
d3.area()
.x(d => xScale(d.data.año))
.y0(d => yScale(d[0]))
.y1(d => yScale(d[1]))
.curve(d3.curveBasis)
);
return () => {
observer.disconnect();
resizeObserver.disconnect();
};
});
</script>
<svelte:head>
<title>Streamgraph - Ingresos del Estado</title>
<title>{entidad.nombre} - Radiografía</title>
</svelte:head>
<div class="page">
<header>
<h1>Ingresos del Estado Boliviano</h1>
<p class="subtitle">Streamgraph interactivo con drilldown · 2005-2025</p>
<div class="dashboard">
<!-- Header -->
<header class="dashboard-header">
<div class="header-info">
<h1 class="entidad-nombre">{entidad.nombre}</h1>
<div class="entidad-meta">
<span class="entidad-sigla">{entidad.sigla}</span>
<span class="meta-sep">·</span>
<span class="entidad-sector">{entidad.sector}</span>
</div>
</div>
<div class="header-control">
<label class="gestion-label">Gestión</label>
<select class="gestion-select" bind:value={gestionSeleccionada}>
{#each gestiones as g}
<option value={g}>{g}</option>
{/each}
</select>
</div>
</header>
<!-- Breadcrumb -->
<nav class="breadcrumb">
{#each breadcrumb as crumb, i}
{#if i > 0}
<span class="separator">→</span>
{/if}
<button
class="crumb"
class:active={i === breadcrumb.length - 1}
onclick={() => navigateTo(i)}
disabled={i === breadcrumb.length - 1}
>
{crumb.descripcion}
</button>
{/each}
</nav>
<!-- Nivel actual -->
<div class="level-indicator">
Nivel: <strong>{currentLevel}</strong>
{#if currentLevel !== 'subcuenta'}
<span class="hint">· Clic en una banda para ver detalle</span>
{/if}
<!-- Ranking -->
<div class="ranking-bar">
<div class="ranking-item">
<span class="ranking-label">Ranking gastos</span>
<span class="ranking-value">#{ranking.gastos.posicion}</span>
<span class="ranking-total">de {ranking.gastos.total}</span>
</div>
<div class="ranking-sep"></div>
<div class="ranking-item">
<span class="ranking-label">Ranking ingresos</span>
<span class="ranking-value">#{ranking.ingresos.posicion}</span>
<span class="ranking-total">de {ranking.ingresos.total}</span>
</div>
</div>
<!-- Contenedor del gráfico -->
<div class="chart-container" bind:this={container}>
{#if loading}
<div class="loading">Cargando datos...</div>
{:else if width > 0 && prepared.series.length > 0}
<svg {width} {height}>
<!-- Eje X -->
<g class="axis-x" transform="translate(0, {height - 35})">
{#each prepared.years.filter((_, i) => i % 2 === 0) as year}
<text x={xScale(year)} y="20" text-anchor="middle" class="axis-label">
{year}
</text>
<line
x1={xScale(year)}
y1="-{height - 80}"
x2={xScale(year)}
y2="0"
class="grid-line"
/>
{/each}
</g>
<!-- Streams -->
{#each prepared.series as serie, i}
{@const color = colorScale(serie.key)}
<path
d={areaGenerator(serie)}
fill={color}
fill-opacity="0.85"
stroke={color}
stroke-width="0.5"
class="stream-path"
class:clickable={currentLevel !== 'subcuenta'}
onmouseenter={(e) => {
tooltip = {
show: true,
x: e.clientX,
y: e.clientY,
data: {
codigo: serie.key,
descripcion: serie.descripcion,
color,
total: d3.sum(serie, d => d[1] - d[0])
}
};
}}
onmousemove={(e) => {
tooltip.x = e.clientX;
tooltip.y = e.clientY;
}}
onmouseleave={() => tooltip.show = false}
onclick={() => handleClick(+serie.key, serie.descripcion)}
/>
{/each}
</svg>
<!-- Leyenda -->
<div class="legend">
{#each prepared.series as serie}
{@const color = colorScale(serie.key)}
<button
class="legend-item"
onclick={() => handleClick(+serie.key, serie.descripcion)}
>
<span class="legend-color" style="background: {color}"></span>
<span class="legend-label">{serie.descripcion}</span>
</button>
<!-- Historia temporal -->
<section class="seccion">
<h2 class="seccion-titulo">Historia 2005 – 2025</h2>
<div class="historia-grid">
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Ingresos</span>
<span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].monto)} Bs</span>
</div>
<div class="historia-chart" bind:this={chartIngresosContainer}></div>
</div>
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Gastos</span>
<span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].monto)} Bs</span>
</div>
<div class="historia-chart" bind:this={chartGastosContainer}></div>
</div>
</div>
</section>
<!-- Composición del gasto -->
<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)}
{@const total = getTotal(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-nombre">{displayItem?.nombre || ''}</span>
<span class="clasificador-padre">({displayItem?.padre || ''})</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
{:else}
<div class="no-data">No hay datos para este nivel</div>
{/if}
</section>
<!-- Origen del dinero -->
<section class="seccion">
<h2 class="seccion-titulo">¿De dónde viene la plata? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresIngreso as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
{@const total = getTotal(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-nombre">{displayItem?.nombre || ''}</span>
<span class="clasificador-padre">({displayItem?.padre || ''})</span>
</div>
<!-- Tooltip -->
{#if tooltip.show && tooltip.data}
<div
class="tooltip"
style="left: {tooltip.x + 15}px; top: {tooltip.y - 10}px;"
>
<div class="tooltip-header" style="border-color: {tooltip.data.color}">
<span class="tooltip-code">{tooltip.data.codigo}</span>
<span class="tooltip-desc">{tooltip.data.descripcion}</span>
<span class="clasificador-label">{label}</span>
</div>
<div class="tooltip-value">
Total acumulado: {formatMoney(tooltip.data.total)}
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{#if currentLevel !== 'subcuenta'}
<div class="tooltip-hint">Clic para ver detalle</div>
{/if}
{/each}
</div>
{/if}
</section>
</div>
<style>
.page {
.dashboard {
min-height: 100vh;
background: #1a1a1a;
color: #f5f5f5;
background: var(--theme-fondo);
color: var(--theme-texto);
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
font-family: var(--font-sans);
}
header {
text-align: center;
margin-bottom: 2rem;
/* Header */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--theme-borde);
}
h1 {
font-size: 2rem;
.entidad-nombre {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: #f5f5f5;
color: var(--theme-titulo);
margin: 0 0 0.5rem 0;
line-height: 1.2;
}
.entidad-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--theme-texto);
opacity: 0.7;
}
.entidad-sigla {
font-weight: 600;
color: var(--theme-titulo);
opacity: 1;
}
.meta-sep {
opacity: 0.4;
}
.header-control {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.subtitle {
color: #888;
margin-top: 0.5rem;
.gestion-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.6;
}
.breadcrumb {
.gestion-select {
padding: 0.5rem 1rem;
font-size: 0.95rem;
font-weight: 600;
background: var(--theme-tarjeta);
border: 1px solid var(--theme-borde);
border-radius: 8px;
color: var(--theme-titulo);
cursor: pointer;
}
/* 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;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.separator {
color: #555;
.ranking-label {
font-size: 0.8rem;
color: var(--theme-texto);
opacity: 0.7;
}
.crumb {
background: #2a2a2a;
border: 1px solid #333;
color: #aaa;
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
.ranking-value {
font-size: 1.25rem;
font-weight: 700;
color: #C9A751;
}
.crumb:hover:not(:disabled) {
background: #333;
color: #fff;
.ranking-total {
font-size: 0.8rem;
color: var(--theme-texto);
opacity: 0.5;
}
.crumb.active {
background: #C9A751;
color: #1a1a1a;
font-weight: 600;
cursor: default;
.ranking-sep {
width: 1px;
height: 24px;
background: var(--theme-borde);
}
.crumb:disabled {
cursor: default;
/* Secciones */
.seccion {
margin-bottom: 2.5rem;
}
.level-indicator {
color: #666;
font-size: 0.85rem;
margin-bottom: 1rem;
.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;
}
.level-indicator strong {
.gestion-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.6rem;
background: rgba(201, 167, 81, 0.15);
color: #C9A751;
text-transform: uppercase;
border-radius: 4px;
}
.hint {
color: #555;
/* Historia */
.historia-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.chart-container {
background: #222;
.historia-card {
background: var(--theme-tarjeta);
border-radius: 12px;
padding: 1rem;
min-height: 500px;
position: relative;
}
.loading, .no-data {
.historia-header {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
color: #666;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.75rem;
}
svg {
display: block;
.historia-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-texto);
}
.axis-label {
fill: #666;
font-size: 12px;
.historia-monto {
font-size: 0.95rem;
font-weight: 600;
}
.grid-line {
stroke: #333;
stroke-width: 0.5;
stroke-dasharray: 2, 4;
.historia-monto.ingresos {
color: #4A8B6E;
}
.stream-path {
transition: fill-opacity 0.2s, transform 0.2s;
.historia-monto.gastos {
color: #C9A751;
}
.stream-path:hover {
fill-opacity: 1;
.historia-chart {
width: 100%;
height: 180px;
}
.stream-path.clickable {
cursor: pointer;
/* Clasificadores */
.clasificadores-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stream-path.clickable:hover {
filter: brightness(1.1);
.clasificador-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.legend {
.clasificador-header {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #333;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.legend-item {
.clasificador-info {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.4rem;
background: #2a2a2a;
border: 1px solid #333;
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
flex-wrap: wrap;
}
.legend-item:hover {
background: #333;
.clasificador-monto {
font-size: 0.95rem;
font-weight: 600;
color: var(--theme-titulo);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
.clasificador-nombre {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-titulo);
}
.clasificador-padre {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.5;
}
.clasificador-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.6;
flex-shrink: 0;
}
.legend-label {
font-size: 0.75rem;
color: #aaa;
max-width: 150px;
.barra-container {
width: 100%;
height: 48px;
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tooltip {
position: fixed;
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(8px);
border: 1px solid #444;
border-radius: 8px;
padding: 0.75rem 1rem;
pointer-events: none;
z-index: 1000;
max-width: 300px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
/* Responsive */
@media (max-width: 768px) {
.dashboard {
padding: 1rem;
}
.tooltip-header {
border-left: 3px solid;
padding-left: 0.5rem;
margin-bottom: 0.5rem;
.dashboard-header {
flex-direction: column;
gap: 1rem;
}
.tooltip-code {
font-family: monospace;
font-size: 0.75rem;
color: #888;
display: block;
.entidad-nombre {
font-size: 1.35rem;
}
.tooltip-desc {
font-size: 0.9rem;
color: #f5f5f5;
font-weight: 500;
.historia-grid {
grid-template-columns: 1fr;
}
.tooltip-value {
font-size: 0.85rem;
color: #C9A751;
font-weight: 600;
.ranking-bar {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.tooltip-hint {
font-size: 0.75rem;
color: #666;
margin-top: 0.3rem;
.ranking-sep {
width: 100%;
height: 1px;
}
}
</style>
......
# Estilos UI - Dark Mode, Menu y Drawer
## 1. Variables CSS (app.css)
```css
/* Light mode colors */
:root {
--color-light-body: #f5ebe0;
--color-light-background: #f3ead8;
--color-light-titulo: #363534;
--color-light-texto-auxiliar: #676767;
--color-light-fill: #f3ead8;
--color-light-borde: rgba(0, 0, 0, 0.08);
--color-light-focus-secondary: #676767;
--color-light-accent: #c9a751;
}
/* Dark mode colors */
:root.dark {
--color-light-body: #0d0d0d;
--color-light-background: #1a1a1a;
--color-light-titulo: #e8e4d8;
--color-light-texto-auxiliar: #b8b3a3;
--color-light-fill: #2d2d2d;
--color-light-borde: rgba(255, 255, 255, 0.1);
--color-light-focus-secondary: #c9a751;
--color-light-accent: #c9a751;
}
```
---
## 2. Inicialización del tema (app.html)
Evita el flash de tema incorrecto cargando la preferencia antes del render.
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Theme initialization - ANTES de cargar CSS -->
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
let isDark = false;
if (savedTheme) {
isDark = savedTheme === 'dark';
}
if (isDark) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<!-- contenido -->
</body>
</html>
```
---
## 3. BARRA DE CONTROLES (Pastilla con iconos)
Esta es la barra que contiene: **Home + Theme Toggle + Menú Hamburguesa**
### Estructura completa
```svelte
<script>
import { onMount } from 'svelte';
let isDark = $state(false);
let drawerOpen = $state(false);
function toggleTheme() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
function toggleDrawer() {
drawerOpen = !drawerOpen;
}
onMount(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
isDark = savedTheme === 'dark';
}
document.documentElement.classList.toggle('dark', isDark);
});
</script>
<!-- BARRA DE CONTROLES - Esquina superior derecha -->
<div class="fixed top-6 right-6 z-[110]">
<div class="flex items-center gap-1">
<!-- ========== BOTÓN HOME (4 cuadrados) ========== -->
<div class="relative group/home">
<a
href="/"
class="p-2 rounded-full transition-all block"
style="background-color: transparent; color: var(--color-light-texto-auxiliar);"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</svg>
</a>
<!-- Tooltip -->
<div class="absolute right-0 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover/home:opacity-100 group-hover/home:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Página principal
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- ========== BOTÓN THEME TOGGLE (Sol/Luna) ========== -->
<div class="relative group/theme">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDark}
<!-- LUNA (modo oscuro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<!-- SOL (modo claro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
<!-- Tooltip -->
<div class="absolute right-0 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover/theme:opacity-100 group-hover/theme:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
{isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- ========== BOTÓN MENÚ HAMBURGUESA ========== -->
<div class="relative group/menu">
<button
onclick={toggleDrawer}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
<!-- Tooltip -->
<div class="absolute right-0 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover/menu:opacity-100 group-hover/menu:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Menú
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
</div>
</div>
```
### Estilos clave de cada botón
| Propiedad | Valor |
|-----------|-------|
| Padding | `p-2` (8px) |
| Forma | `rounded-full` (circular) |
| Fondo | `transparent` |
| Color normal | `var(--color-light-texto-auxiliar)` |
| Color hover | `var(--color-light-titulo)` |
| Tamaño icono | `w-5 h-5` (20px) |
| Transición | `transition-all` |
| Cursor | `cursor: pointer` |
### Estilos del contenedor (la "pastilla")
| Propiedad | Valor |
|-----------|-------|
| Layout | `flex items-center` |
| Espaciado | `gap-1` (4px entre iconos) |
| Posición | `fixed top-6 right-6` |
| Z-index | `z-[110]` |
### Tooltip (aparece abajo del icono)
| Propiedad | Valor |
|-----------|-------|
| Posición | `absolute right-0 top-full mt-2` |
| Fondo | `bg-gray-900/90` |
| Texto | `text-white text-xs` |
| Padding | `px-3 py-1.5` |
| Bordes | `rounded-lg` |
| Efecto | `backdrop-blur-sm` |
| Transición | `opacity-0 invisible``opacity-100 visible` en hover |
| Flecha | `w-2 h-2 bg-gray-900/90 rotate-45` posicionada arriba |
---
## 4. VERSIÓN MÓVIL (Solo hamburguesa + theme en drawer)
En móvil, solo se muestra el botón hamburguesa. El theme toggle va dentro del drawer.
```svelte
<!-- Móvil: solo hamburguesa arriba a la derecha -->
<div class="fixed top-2 right-2 z-[110] md:hidden">
<button
onclick={toggleDrawer}
class="p-2 rounded-full transition-all flex-shrink-0"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
</div>
<!-- Desktop: barra completa con home + theme + menu -->
<div class="hidden md:flex fixed top-6 right-6 z-[110]">
<!-- ... los 3 botones ... -->
</div>
```
---
## 5. NavigationDrawer.svelte (Drawer completo)
```svelte
<script>
import { onMount } from 'svelte';
let { isOpen = $bindable(false) } = $props();
let isDarkMode = $state(false);
// Detectar cambios de tema
onMount(() => {
isDarkMode = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDarkMode = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
});
function closeDrawer() {
isOpen = false;
}
function toggleTheme() {
isDarkMode = !isDarkMode;
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}
</script>
<!-- Overlay (fondo oscuro) -->
{#if isOpen}
<div
class="fixed inset-0 bg-black/40 z-[200]"
style="opacity: {isOpen ? '1' : '0'}; transition: opacity 0.2s ease-out;"
onclick={closeDrawer}
></div>
{/if}
<!-- Drawer -->
<aside
class="fixed top-0 right-0 h-full w-80 border-l shadow-2xl z-[201]"
style="
background-color: var(--color-light-body);
border-color: rgba(0, 0, 0, 0.05);
transform: {isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)'};
transition: transform 0.3s cubic-bezier(0.33, 1, 0.68, 1);
"
>
<div class="flex flex-col h-full">
<!-- Header del Drawer -->
<div class="py-3 md:py-4 px-4 md:px-6 border-b flex-shrink-0 relative" style="border-color: rgba(0, 0, 0, 0.05);">
<!-- Theme Toggle - SOLO en móvil (arriba izquierda del drawer) -->
<div class="absolute top-3 md:top-4 left-4 md:left-6 md:hidden">
<button
onclick={toggleTheme}
class="p-2 rounded-full transition-all"
style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
>
{#if isDarkMode}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
</div>
<!-- Botón cerrar (X) - arriba derecha -->
<div class="group absolute top-3 md:top-4 right-4 md:right-6">
<button
onclick={closeDrawer}
class="p-2 rounded-lg transition-colors flex-shrink-0"
style="color: var(--color-light-focus-secondary); cursor: pointer;"
aria-label="Cerrar menú"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Tooltip del botón cerrar -->
<div class="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Cerrar menú
<div class="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
<!-- Logo centrado (cambia según tema) -->
<div class="flex flex-col items-center">
<img
src={isDarkMode ? "/logos_oscuro/logo.png" : "/logos_claro/logo.png"}
alt="Logo"
class="h-24 md:h-32 w-auto mb-3"
/>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto p-4">
<!-- Item de navegación con hover -->
<a
href="/"
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left group"
onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-light-fill)'}
onmouseleave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--color-light-focus-secondary);" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</svg>
<span class="font-medium text-sm" style="color: var(--color-light-titulo);">Página principal</span>
</a>
<!-- Sección con título -->
<div class="mb-6 mt-4">
<h3 class="text-xs font-semibold uppercase tracking-wider mb-3 px-2" style="color: var(--color-light-focus-secondary);">
Sección
</h3>
<div class="space-y-1">
<!-- Items de navegación aquí -->
</div>
</div>
</nav>
<!-- Footer -->
<div class="p-4 border-t flex-shrink-0" style="border-color: rgba(0, 0, 0, 0.05);">
<p class="text-xs text-center" style="color: var(--color-light-focus-secondary);">
© 2026
</p>
</div>
</div>
</aside>
```
---
## 6. Iconos SVG
### Sol (modo claro activo)
```html
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
```
### Luna (modo oscuro activo)
```html
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
```
### Hamburguesa (menú)
```html
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
```
### X (cerrar)
```html
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
```
### Home (4 cuadrados)
```html
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
</svg>
```
---
## 7. Resumen de comportamiento
| Viewport | Qué se ve | Theme toggle |
|----------|-----------|--------------|
| **Móvil** (`<768px`) | Solo hamburguesa arriba derecha | Dentro del drawer (arriba izquierda) |
| **Desktop** (`≥768px`) | Home + Theme + Hamburguesa | En la barra de controles |
### Animaciones
| Elemento | Animación |
|----------|-----------|
| Hover en iconos | Color cambia de `texto-auxiliar``titulo` |
| Tooltip | `opacity 0→1`, `invisible→visible` con `duration-200` |
| Drawer abre | `translate3d(100%, 0, 0)``translate3d(0, 0, 0)` con `cubic-bezier(0.33, 1, 0.68, 1)` |
| Overlay | `opacity 0→1` con `0.2s ease-out` |
# Estructura de Bases de Datos - Presupuesto Público Bolivia
Este documento detalla las tablas, vistas y sus estructuras en el esquema `ppto` de Supabase.
---
## Tablas de Clasificadores
### 1. `clas_institucional` - Clasificador Institucional
| Columna | Tipo | Descripción |
|---------|------|-------------|
| entidad | INTEGER | Código único de la entidad |
| desc_entidad | TEXT | Nombre completo de la entidad |
| sigla_entidad | VARCHAR(50) | Sigla de la entidad |
| sector | VARCHAR(10) | Código del sector |
| desc_sector | TEXT | Nombre del sector |
| sigla_sector | VARCHAR(20) | Sigla del sector |
| subsector | VARCHAR(10) | Código del subsector |
| desc_subsector | TEXT | Nombre del subsector |
| sigla_subsector | VARCHAR(20) | Sigla del subsector |
| area | VARCHAR(10) | Código del área |
| desc_area | TEXT | Nombre del área |
| sigla_area | VARCHAR(20) | Sigla del área |
| subarea | VARCHAR(10) | Código de la subárea |
| desc_subarea | TEXT | Nombre de la subárea |
| sigla_subarea | VARCHAR(20) | Sigla de la subárea |
| gestiones | TEXT | Lista CSV de años con datos (ej: "2006,2007,...,2025") |
| n_gestiones | INTEGER | Cantidad de años con datos |
---
### 2. `clas_objetos` - Clasificador de Objetos de Gasto
| Columna | Tipo | Descripción |
|---------|------|-------------|
| nivel | TEXT | Nivel jerárquico: 'grupo', 'subgrupo', 'partida', 'subpartida' |
| objeto | TEXT (PK) | Código del objeto (ej: "10000", "11000", "11100") |
| desc_objeto | TEXT | Descripción vigente del objeto de gasto |
| gestiones | TEXT | Rangos de años donde existe (ej: "2005, 2007-2020, 2022-2026") |
| n_gestiones | INTEGER | Cantidad de años con datos |
| n_variaciones | INTEGER | Cantidad de variaciones históricas en la descripción |
| descripciones | JSONB | Historial de descripciones por rangos de años |
**Estructura de `descripciones`:**
```json
[
{
"descripcion": "Descripción vigente del objeto...",
"rangos": "2011-2020, 2022-2026"
},
{
"descripcion": "Descripción anterior del objeto...",
"rangos": "2005, 2007-2010"
}
]
```
**Jerarquía derivada del código `objeto`:**
```
Nivel Patrón Ejemplo
─────────────────────────────────
grupo X0000 10000, 20000
subgrupo XX000 11000, 12000
partida XXX00 11100, 11200
subpartida XXXX0/X 11110, 11111
```
**Sin índices.** La tabla tiene ~500 filas y se carga completa al inicio.
---
### 3. `clas_rubros` - Clasificador de Rubros (Ingresos)
| Columna | Tipo | Descripción |
|---------|------|-------------|
| nivel | TEXT | Nivel jerárquico: 'tipo', 'clase', 'cuenta', 'sub_cuenta' |
| rubro | TEXT (PK) | Código del rubro (ej: "1000", "1100", "1110") |
| desc_rubro | TEXT | Descripción vigente del rubro |
| gestiones | TEXT | Rangos de años donde existe (ej: "2005, 2007-2020, 2022-2026") |
| n_gestiones | INTEGER | Cantidad de años con datos |
| n_variaciones | INTEGER | Cantidad de variaciones históricas en la descripción |
| descripciones | JSONB | Historial de descripciones por rangos de años |
**Estructura de `descripciones`:**
```json
[
{
"descripcion": "Descripción vigente del rubro...",
"rangos": "2011-2020, 2022-2026"
},
{
"descripcion": "Descripción anterior del rubro...",
"rangos": "2005, 2007-2010"
}
]
```
**Jerarquía derivada del código `rubro`:**
```
Nivel Patrón Ejemplo
──────────────────────────────────
tipo X000 1000, 2000
clase XX00 1100, 1200
cuenta XXX0 1110, 1120
sub_cuenta XXXX 1111, 1112
```
**Sin índices.** La tabla tiene ~400 filas y se carga completa al inicio.
---
### 4. `clas_finfun` - Clasificador de Finalidad y Función
| Columna | Tipo | Descripción |
|---------|------|-------------|
| nivel | TEXT | Nivel jerárquico: 'finalidad', 'grpfuncion', 'funcion' |
| finfun | TEXT (PK) | Código de finalidad/función (ej: "1", "11", "111") |
| desc_finfun | TEXT | Descripción vigente de la finalidad/función |
| gestiones | TEXT | Rangos de años donde existe (ej: "2005, 2007-2020, 2022-2026") |
| n_gestiones | INTEGER | Cantidad de años con datos |
| n_variaciones | INTEGER | Cantidad de variaciones históricas en la descripción |
| descripciones | JSONB | Historial de descripciones por rangos de años |
**Estructura de `descripciones`:**
```json
[
{
"descripcion": "Descripción vigente de la finalidad/función...",
"rangos": "2011-2020, 2022-2026"
},
{
"descripcion": "Descripción anterior...",
"rangos": "2005, 2007-2010"
}
]
```
**Jerarquía derivada del código `finfun`:**
```
Nivel Patrón Ejemplo
──────────────────────────────────
finalidad X 1, 2, 3
grpfuncion XX 11, 12, 21
funcion XXX+ 111, 112, 211
```
**Sin índices.** La tabla tiene ~200 filas y se carga completa al inicio.
---
## Tablas de Datos Agregados
### 5. `treemap_objeto` - Datos para Treemap de Objetos de Gasto
| Columna | Tipo | Descripción |
|---------|------|-------------|
| gestion | INTEGER | Año fiscal (2005-2025) |
| entidad | INTEGER | Código de entidad (0 = Todo el Estado) |
| nivel | VARCHAR(20) | Nivel: 'grupo', 'subgrupo', 'partida', 'subpartida' |
| objeto | INTEGER | Código del objeto |
| desc_objeto | TEXT | Descripción del objeto |
| parent | INTEGER | Código del objeto padre (para jerarquía) |
| devengado | NUMERIC | Monto devengado en Bs |
**Índices recomendados:**
```sql
CREATE INDEX idx_treemap_objeto_gestion_entidad ON ppto.treemap_objeto(gestion, entidad);
```
**Notas:**
- ~1.4 millones de filas
- Entidad 0 representa el agregado de todo el estado
- Cada combinación gestión + entidad debe cargarse como unidad
---
### 6. `entidades_treemap` - Índice de Entidades por Año (Treemap)
| Columna | Tipo | Descripción |
|---------|------|-------------|
| entidad | INTEGER | Código de la entidad |
| gestion | INTEGER | Año fiscal |
| desc_entidad | TEXT | Nombre de la entidad |
| sigla_entidad | VARCHAR(50) | Sigla de la entidad |
**Índices recomendados:**
```sql
CREATE INDEX idx_entidades_treemap_gestion ON ppto.entidades_treemap(gestion);
```
**SQL para crear (derivada de treemap_objeto):**
```sql
CREATE TABLE ppto.entidades_treemap AS
SELECT DISTINCT
t.entidad,
t.gestion,
c.desc_entidad,
c.sigla_entidad
FROM ppto.treemap_objeto t
LEFT JOIN ppto.clas_institucional c ON t.entidad = c.entidad
WHERE t.entidad > 0;
CREATE INDEX idx_entidades_treemap_gestion ON ppto.entidades_treemap(gestion);
GRANT SELECT ON ppto.entidades_treemap TO anon, authenticated;
```
**Uso:** Poblar dropdown de entidades filtrado por año seleccionado.
---
### 7. `entidades_por_objeto` - Relación Entidades-Objetos
| Columna | Tipo | Descripción |
|---------|------|-------------|
| entidad | INTEGER | Código de la entidad |
| entidad_desc | TEXT | Nombre de la entidad |
| objeto | INTEGER | Código del objeto |
| nivel | VARCHAR(20) | Nivel del objeto |
**Índices recomendados:**
```sql
CREATE INDEX idx_entidades_por_objeto_objeto ON ppto.entidades_por_objeto(objeto);
CREATE INDEX idx_entidades_por_objeto_objeto_nivel ON ppto.entidades_por_objeto(objeto, nivel);
```
**Uso:** Poblar dropdowns de entidades al ver un objeto específico.
---
### 8. `vista_finfun_estado` - Vista Agregada por Finalidad/Función (Todo el Estado)
| Columna | Tipo | Descripción |
|---------|------|-------------|
| gestion | INTEGER | Año fiscal |
| finfun | TEXT | Código de finalidad/función |
| total | NUMERIC | Monto total devengado |
| poblacion | INTEGER | Población del año |
| n_entidades | INTEGER | Cantidad de entidades con datos |
| per_capita | NUMERIC | Gasto per cápita (total/población) |
| total_gestion | NUMERIC | Total del Estado en esa gestión |
| prop | NUMERIC | Proporción sobre el total del Estado |
| ranking | INTEGER | Ranking por monto dentro de su nivel |
| n_pares | INTEGER | Cantidad de códigos del mismo nivel |
| top1_entidad | INTEGER | Código de la entidad con mayor gasto |
| top1_entidad_desc | TEXT | Nombre de la entidad top 1 |
| top1_monto | NUMERIC | Monto de la entidad top 1 |
| top1_pct | NUMERIC | Porcentaje del total |
| top2_entidad | INTEGER | Código de la entidad top 2 |
| top2_entidad_desc | TEXT | Nombre de la entidad top 2 |
| top2_monto | NUMERIC | Monto de la entidad top 2 |
| top2_pct | NUMERIC | Porcentaje del total |
| top3_entidad | INTEGER | Código de la entidad top 3 |
| top3_entidad_desc | TEXT | Nombre de la entidad top 3 |
| top3_monto | NUMERIC | Monto de la entidad top 3 |
| top3_pct | NUMERIC | Porcentaje del total |
**Índices recomendados:**
```sql
CREATE INDEX idx_vista_finfun_estado_finfun ON ppto.vista_finfun_estado(finfun);
CREATE INDEX idx_vista_finfun_estado_finfun_gestion ON ppto.vista_finfun_estado(finfun, gestion);
```
---
### 9. `vista_finfun_entidad` - Vista por Finalidad/Función y Entidad
| Columna | Tipo | Descripción |
|---------|------|-------------|
| gestion | INTEGER | Año fiscal |
| finfun | TEXT | Código de finalidad/función |
| nivel | TEXT | Nivel: 'finalidad', 'grpfuncion', 'funcion' |
| entidad | INTEGER | Código de la entidad |
| entidad_desc | TEXT | Nombre de la entidad |
| monto | NUMERIC | Monto devengado |
| per_capita | NUMERIC | Gasto per cápita |
| ranking | INTEGER | Ranking de la entidad para ese finfun |
| prop | NUMERIC | Proporción sobre el total del finfun |
| n_entidades | INTEGER | Total de entidades con ese finfun |
**Índices recomendados:**
```sql
CREATE INDEX idx_vista_finfun_entidad_finfun ON ppto.vista_finfun_entidad(finfun);
CREATE INDEX idx_vista_finfun_entidad_finfun_entidad ON ppto.vista_finfun_entidad(finfun, entidad);
```
---
### 10. `entidades_por_finfun` - Relación Entidades-Finalidad/Función
| Columna | Tipo | Descripción |
|---------|------|-------------|
| finfun | TEXT | Código de finalidad/función |
| nivel | TEXT | Nivel: 'finalidad', 'grpfuncion', 'funcion' |
| entidad | INTEGER | Código de la entidad |
| entidad_desc | TEXT | Nombre de la entidad |
**Índices recomendados:**
```sql
CREATE INDEX idx_entidades_por_finfun_finfun ON ppto.entidades_por_finfun(finfun);
CREATE INDEX idx_entidades_por_finfun_finfun_nivel ON ppto.entidades_por_finfun(finfun, nivel);
```
**Uso:** Poblar dropdowns de entidades al ver una finalidad/función específica.
---
## Vistas Materializadas / Tablas de Consulta
### 11. `vista_objeto_estado` - Vista Agregada por Objeto (Todo el Estado)
| Columna | Tipo | Descripción |
|---------|------|-------------|
| objeto | INTEGER | Código del objeto |
| gestion | INTEGER | Año fiscal |
| devengado | NUMERIC | Monto total devengado |
| top1_entidad | TEXT | Entidad con mayor gasto |
| top1_monto | NUMERIC | Monto de la entidad top 1 |
| top1_pct | NUMERIC | Porcentaje del total |
| top2_entidad | TEXT | Segunda entidad con mayor gasto |
| top2_monto | NUMERIC | Monto de la entidad top 2 |
| top2_pct | NUMERIC | Porcentaje del total |
| top3_entidad | TEXT | Tercera entidad con mayor gasto |
| top3_monto | NUMERIC | Monto de la entidad top 3 |
| top3_pct | NUMERIC | Porcentaje del total |
**Índices recomendados:**
```sql
CREATE INDEX idx_vista_objeto_estado_objeto ON ppto.vista_objeto_estado(objeto);
CREATE INDEX idx_vista_objeto_estado_objeto_gestion ON ppto.vista_objeto_estado(objeto, gestion);
```
---
### 12. `vista_objeto_entidad` - Vista por Objeto y Entidad
| Columna | Tipo | Descripción |
|---------|------|-------------|
| objeto | INTEGER | Código del objeto |
| nivel | VARCHAR(20) | Nivel del objeto |
| entidad | INTEGER | Código de la entidad |
| gestion | INTEGER | Año fiscal |
| devengado | NUMERIC | Monto devengado |
**Índices recomendados:**
```sql
CREATE INDEX idx_vista_objeto_entidad_objeto ON ppto.vista_objeto_entidad(objeto);
CREATE INDEX idx_vista_objeto_entidad_objeto_entidad ON ppto.vista_objeto_entidad(objeto, entidad);
CREATE INDEX idx_vista_objeto_entidad_objeto_nivel_entidad ON ppto.vista_objeto_entidad(objeto, nivel, entidad);
```
---
## Jerarquías
### Objetos de Gasto (4 niveles)
```
Grupo (1xxxx) → Subgrupo (11xxx) → Partida (111xx) → Subpartida (11110)
```
### Rubros de Ingresos (4 niveles)
```
Tipo → Clase → Cuenta → Subcuenta
```
### Finalidad y Función (3 niveles)
```
Finalidad (X) → Grupo Función (XX) → Función (XXX)
```
### Institucional (5 niveles)
```
Sector → Subsector → Área → Subárea → Entidad
```
---
*Documento generado para el equipo de sistemas - Marzo 2026*