Rafael Lopez

nuevo landing

1 +{
2 + "facet_counts": [],
3 + "found": 49,
4 + "out_of": 735538,
5 + "page": 1,
6 + "search_time_ms": 96,
7 + "hits": [
8 + {
9 + "document": {
10 + "gestion": 2017,
11 + "devengado": 6544172.35,
12 + "is_class": false,
13 + "class_": "programa_proyecto",
14 + "texto": "CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA LINERA-COPALANI",
15 + "metadatos": {
16 + "entidad": 1260,
17 + "programa": 21,
18 + "proyecto": "112",
19 + "actividad": 0.0
20 + }
21 + },
22 + "highlights": [
23 + {
24 + "field": "texto",
25 + "matched_tokens": ["LINERA"],
26 + "snippet": "CONST. 15 AULAS Y TINGLADO U.E. TECNICO HUMANISTICO ALVARO GARCIA <mark>LINERA</mark>-COPALANI"
27 + }
28 + ]
29 + },
30 + {
31 + "document": {
32 + "gestion": 2018,
33 + "devengado": 6500010.17,
34 + "is_class": false,
35 + "class_": "programa_proyecto",
36 + "texto": "CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA LINERA",
37 + "metadatos": {
38 + "entidad": 1347,
39 + "programa": 21,
40 + "proyecto": "110",
41 + "actividad": 0.0
42 + }
43 + },
44 + "highlights": [
45 + {
46 + "field": "texto",
47 + "matched_tokens": ["LINERA"],
48 + "snippet": "CONST. INSTITUTO TECNOLOGICO ALVARO GARCIA <mark>LINERA</mark>"
49 + }
50 + ]
51 + }
52 + ]
53 +}
...\ No newline at end of file ...\ No newline at end of file
1 +# Refactor del Buscador - Landing Page
2 +
3 +## Naturaleza del Proyecto
4 +
5 +**¿Para quién?** Ciudadanos bolivianos (público general, no técnicos)
6 +
7 +**¿De quién?** Gobierno boliviano - Ministerio de Economía y Finanzas Públicas
8 +
9 +**¿Qué es?** Datos abiertos de contabilidad fiscal (presupuesto público)
10 +
11 +**El mensaje central**: "Puedes buscar donde quieras" - transparencia, accesibilidad, apertura
12 +
13 +El buscador de la landing actual es un **statement visual** de ese mensaje. El nuevo buscador debe mantener ese espíritu.
14 +
15 +---
16 +
17 +## Nueva Ruta: `/nuevo-buscador`
18 +
19 +Ruta dedicada al buscador completo con todas las capacidades:
20 +- Selector Ingresos/Gastos
21 +- Selector Programas/Clasificadores (solo gastos)
22 +- Selector de clasificador específico
23 +- Resultados ricos tipo Spotlight
24 +
25 +**Archivos**:
26 +```
27 +src/routes/nuevo-buscador/
28 + └── +page.svelte
29 +```
30 +
31 +---
32 +
33 +## Command K (Buscador Global) - EFECTO DEL REFACTOR
34 +
35 +### Descripción
36 +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.
37 +
38 +### Archivos involucrados
39 +```
40 +src/lib/components/ui/SearchModal.svelte # Componente del modal
41 +src/routes/+layout.svelte # Monta el modal globalmente (línea 67)
42 +src/lib/components/ui/Navbar.svelte # Botón para abrir el modal
43 +src/lib/stores/searchStore.js # Store compartido con landing
44 +```
45 +
46 +### Implementación actual
47 +- **Activación**: `Cmd+K` / `Ctrl+K` (definido en `+layout.svelte:23-28`)
48 +- **También**: Botón en el Navbar (`onOpenSearch`)
49 +- **Filtros actuales**: 6 clasificadores (entidad, objeto_gasto, rubro, finfun, organismo, fuente)
50 +- **Store**: Usa `searchStore.js` (compartido con landing)
51 +- **NO tiene**: Selector Ingresos/Gastos, Programas/Proyectos
52 +
53 +### Lo que debe cambiar con el refactor
54 +1. Agregar selector Ingresos/Gastos
55 +2. Agregar opción Programas/Proyectos (solo en Gastos)
56 +3. Sincronizar filtros con el buscador principal
57 +4. Posiblemente: recordar última selección del usuario
58 +5. Mostrar las 3 variaciones de diseño de resultados
59 +
60 +### Flujo deseado post-refactor
61 +```
62 +Usuario en cualquier página
63 +
64 +
65 + [Cmd+K]
66 +
67 +
68 +┌─────────────────────────────────────┐
69 +│ SearchModal (replica del central) │
70 +│ │
71 +│ [Ingresos] [Gastos] │
72 +│ [Programas] [Clasificadores] │
73 +│ │
74 +│ Resultados según selección... │
75 +└─────────────────────────────────────┘
76 +```
77 +
78 +---
79 +
80 +## Conceptos Clave
81 +
82 +### Selector Central: Ingresos vs Gastos
83 +Este es el **eje fundamental** que contextualiza toda búsqueda. No es un filtro opcional - define el universo de datos.
84 +
85 +### Clasificadores Compartidos (con contexto distinto)
86 +| Clasificador | En INGRESOS significa | En GASTOS significa |
87 +|--------------|----------------------|---------------------|
88 +| **Geográfico** (dónde) | ¿De dónde vienen los ingresos? | ¿Dónde se ejecuta el gasto? |
89 +| **Entidades** (quién) | ¿Quién recibe? | ¿Quién ejecuta? |
90 +
91 +---
92 +
93 +## Árbol de Decisiones de Búsqueda
94 +
95 +```
96 + ┌─────────────────┐
97 + │ DEFAULT │
98 + │ Gastos+Programas│
99 + └────────┬────────┘
100 +
101 + ┌───────────────┴───────────────┐
102 + ▼ ▼
103 + ┌───────────────┐ ┌───────────────┐
104 + │ INGRESOS │ │ GASTOS │
105 + └───────┬───────┘ └───────┬───────┘
106 + │ │
107 + │ ┌───────┴───────┐
108 + │ ▼ ▼
109 + │ ┌─────────────┐ ┌─────────────┐
110 + │ │ PROGRAMAS/ │ │ CLASIFIC. │
111 + │ │ PROYECTOS │ │ │
112 + │ │ (~600k) │ │ │
113 + │ └─────────────┘ └──────┬──────┘
114 + │ │
115 + ▼ ▼
116 + ┌───────────────┐ ┌───────────────┐
117 + │ CLASIFICADORES│ │ CLASIFICADORES│
118 + │ INGRESOS │ │ GASTOS │
119 + └───────┬───────┘ └───────┬───────┘
120 + │ │
121 + ┌────────────┼────────────┐ ┌─────────────┼─────────────┐
122 + ▼ ▼ ▼ ▼ ▼ ▼
123 +┌────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
124 +│ Rubro │ │Organismo │ │ Fuente │ │ Fin/Fun │ │Obj.Gasto │ │ Sectores │
125 +└────────┘ └──────────┘ └────────┘ └──────────┘ └──────────┘ └──────────┘
126 + │ │
127 + ▼ ▼
128 + ┌───────────────┐ ┌───────────────┐
129 + │ Geográfico │ (compartido) │ Geográfico │
130 + │ (dónde) │ │ (dónde) │
131 + └───────────────┘ └───────────────┘
132 + │ │
133 + ▼ ▼
134 + ┌───────────────┐ ┌───────────────┐
135 + │ Entidades │ (compartido) │ Entidades │
136 + │ (quién) │ │ (quién) │
137 + └───────────────┘ └───────────────┘
138 +```
139 +
140 +---
141 +
142 +## Clasificadores por Modo
143 +
144 +| INGRESOS (5) | GASTOS (5) |
145 +|--------------|------------|
146 +| Rubro | Finalidad y Función |
147 +| Organismo | Objetos de Gasto |
148 +| Fuente | Sectores |
149 +| Geográfico *(dónde)* | Geográfico *(dónde)* |
150 +| Entidades *(quién)* | Entidades *(quién)* |
151 +
152 +**Nota**: Geográfico y Entidades aparecen en ambos pero la vista enfatiza el contexto (ingresos o gastos) según la rama seleccionada.
153 +
154 +---
155 +
156 +## Tabla de Todas las Búsquedas Posibles
157 +
158 +| # | Modo | Submodo | Clasificador | Qué busca |
159 +|---|------|---------|--------------|-----------|
160 +| **0** | **DEFAULT** | - | - | **Gastos + Programas/Proyectos** |
161 +| **INGRESOS** |||||
162 +| 1 | Ingresos | Clasif. | Todos | 5 clasificadores de ingresos |
163 +| 2 | Ingresos | Clasif. | Rubro | Solo rubros |
164 +| 3 | Ingresos | Clasif. | Organismo | Solo organismos |
165 +| 4 | Ingresos | Clasif. | Fuente | Solo fuentes |
166 +| 5 | Ingresos | Clasif. | Geográfico | Geográfico (contexto ingresos) |
167 +| 6 | Ingresos | Clasif. | Entidades | Entidades (contexto ingresos) |
168 +| **GASTOS** |||||
169 +| 7 | Gastos | **Prog/Proy** | - | ~600k registros narrativos |
170 +| 8 | Gastos | Clasif. | Todos | 5 clasificadores de gastos |
171 +| 9 | Gastos | Clasif. | Finalidad/Función | Solo finfun |
172 +| 10 | Gastos | Clasif. | Objeto de gasto | Solo objetos |
173 +| 11 | Gastos | Clasif. | Sectores | Solo sectores |
174 +| 12 | Gastos | Clasif. | Geográfico | Geográfico (contexto gastos) |
175 +| 13 | Gastos | Clasif. | Entidades | Entidades (contexto gastos) |
176 +
177 +**Total: 14 formas de búsqueda** (1 default + 6 ingresos + 7 gastos)
178 +
179 +---
180 +
181 +## Comportamiento Default
182 +
183 +```
184 +┌─────────────────────────────────────────────────────────┐
185 +│ [🔍 Buscar...] │
186 +│ │
187 +│ SIN SELECCIONAR NADA: │
188 +│ → Busca en GASTOS + PROGRAMAS/PROYECTOS │
189 +│ → Lo más intuitivo para el ciudadano común │
190 +│ │
191 +└─────────────────────────────────────────────────────────┘
192 +```
193 +
194 +**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.
195 +
196 +---
197 +
198 +## Las 3 Variaciones de Diseño
199 +
200 +Aunque hay 14 formas de búsqueda posibles, se reducen a **3 variaciones de diseño** de resultados:
201 +
202 +| Variación | Cuándo aplica | Características |
203 +|-----------|---------------|-----------------|
204 +| **1. Programas puro** | Default, Gastos + Programas | Cards narrativas ricas, contexto amplio |
205 +| **2. Clasificador individual** | Cualquier clasificador específico | Lista estructurada, jerárquica |
206 +| **3. Todos los clasificadores** | Ingresos/Gastos + "Todos" | Resultados agrupados por tipo |
207 +
208 +---
209 +
210 +### Variación 1: PROGRAMAS PURO
211 +
212 +**Aplica cuando**: Default, o Gastos + Programas/Proyectos
213 +
214 +**Características**:
215 +- Texto narrativo libre (~600k registros)
216 +- Cards ricas con mucho contexto
217 +- Información: entidad, ubicación, monto, período, ejecución
218 +
219 +```
220 +┌─────────────────────────────────────────────────────────┐
221 +│ 🔍 piscina olimpica │
222 +├─────────────────────────────────────────────────────────┤
223 +│ │
224 +│ ┌─────────────────────────────────────────────────┐ │
225 +│ │ CONST. PISCINA OLIMPICA MUNICIPAL EVO MORALES │ │
226 +│ │ ▸ Cobija · GAM Cobija · Bs 2.3M · 2019-2023 │ │
227 +│ │ ████████░░ 80% ejecutado │ │
228 +│ └─────────────────────────────────────────────────┘ │
229 +│ ┌─────────────────────────────────────────────────┐ │
230 +│ │ PISCINA OLIMPICA DEPARTAMENTAL │ │
231 +│ │ ▸ Tarija · Gobernación · Bs 890k · 2021 │ │
232 +│ │ ███░░░░░░░ 30% ejecutado │ │
233 +│ └─────────────────────────────────────────────────┘ │
234 +│ │
235 +└─────────────────────────────────────────────────────────┘
236 +```
237 +
238 +---
239 +
240 +### Variación 2: CLASIFICADOR INDIVIDUAL
241 +
242 +**Aplica cuando**: Se selecciona UN clasificador específico (rubro, objeto, finfun, etc.)
243 +
244 +**Características**:
245 +- Lista estructurada y limpia
246 +- Muestra jerarquía/nivel
247 +- Código + descripción
248 +
249 +```
250 +┌─────────────────────────────────────────────────────────┐
251 +│ 🔍 construccion [Objetos de gasto ▾] │
252 +├─────────────────────────────────────────────────────────┤
253 +│ │
254 +│ 42000 · Construcciones y mejoras de bienes │
255 +│ ▸ Partida · Nivel 2 │
256 +│ │
257 +│ 42100 · Construcciones de bienes de dominio público │
258 +│ ▸ Partida específica · Nivel 3 │
259 +│ │
260 +│ 42200 · Construcciones de bienes de dominio privado │
261 +│ ▸ Partida específica · Nivel 3 │
262 +│ │
263 +└─────────────────────────────────────────────────────────┘
264 +```
265 +
266 +---
267 +
268 +### Variación 3: TODOS LOS CLASIFICADORES
269 +
270 +**Aplica cuando**: Se selecciona "Todos" los clasificadores (de ingresos o de gastos)
271 +
272 +**Características**:
273 +- Resultados agrupados por tipo de clasificador
274 +- Secciones visuales separadas
275 +- Mezcla de estructuras
276 +
277 +```
278 +┌─────────────────────────────────────────────────────────┐
279 +│ 🔍 educacion [Todos gastos ▾] │
280 +├─────────────────────────────────────────────────────────┤
281 +│ │
282 +│ FINALIDAD Y FUNCIÓN │
283 +│ ┌─────────────────────────────────────────────────┐ │
284 +│ │ 09 · Educación │ │
285 +│ │ ▸ Finalidad │ │
286 +│ └─────────────────────────────────────────────────┘ │
287 +│ │
288 +│ OBJETOS DE GASTO │
289 +│ ┌─────────────────────────────────────────────────┐ │
290 +│ │ 25800 · Servicios de capacitación │ │
291 +│ │ ▸ Partida │ │
292 +│ └─────────────────────────────────────────────────┘ │
293 +│ │
294 +│ ENTIDADES │
295 +│ ┌─────────────────────────────────────────────────┐ │
296 +│ │ Min. de Educación │ │
297 +│ │ ▸ Ministerio · MINEDU │ │
298 +│ └─────────────────────────────────────────────────┘ │
299 +│ │
300 +└─────────────────────────────────────────────────────────┘
301 +```
302 +
303 +---
304 +
305 +## Arquitectura Técnica
306 +
307 +### Motor de Búsqueda
308 +**Typesense** para todo:
309 +- Clasificadores (~1,400 items)
310 +- Programas/Proyectos (~600,000 items)
311 +
312 +### Parámetros de Typesense (ya implementados)
313 +1. **Modo**: Ingresos vs Gastos
314 +2. **Submodo**: Programas vs Clasificadores (solo gastos)
315 +3. **Clasificador específico**: Cuál(es) clasificadores consultar
316 +
317 +### Archivos Principales
318 +```
319 +src/routes/+page.svelte # Landing con buscador
320 +src/lib/services/search.js # Lógica de búsqueda Typesense
321 +src/lib/stores/searchStore.js # Estado reactivo
322 +src/lib/components/search/
323 + ├── SearchBox.svelte # Input reutilizable
324 + ├── SearchResults.svelte # Dropdown resultados
325 + └── SearchResultItem.svelte # Item individual
326 +```
327 +
328 +---
329 +
330 +## Tareas de Implementación
331 +
332 +### Fase 1: UI del Selector
333 +- [ ] Diseñar selector Ingresos/Gastos (visible pero no obligatorio)
334 +- [ ] Diseñar selector Programas/Clasificadores (solo si Gastos)
335 +- [ ] Diseñar selector de clasificador específico
336 +
337 +### Fase 2: Conexión UI ↔ Typesense
338 +- [ ] Mapear estado de selectores a parámetros de Typesense
339 +- [ ] Implementar búsqueda default (Gastos + Programas)
340 +- [ ] Implementar cada una de las 14 formas de búsqueda
341 +
342 +### Fase 3: Diseño de Resultados
343 +- [ ] Diseñar cards ricas para Programas/Proyectos
344 +- [ ] Diseñar cards para cada tipo de clasificador
345 +- [ ] Implementar agrupación visual por tipo
346 +
347 +### Fase 4: Testing
348 +- [ ] Probar las 14 formas de búsqueda
349 +- [ ] Probar UX del selector
350 +- [ ] Probar performance con resultados mixtos
351 +
352 +---
353 +
354 +*Documento creado: 2026-04-04*
355 +*Última actualización: 2026-04-04*
1 +{"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 ...\ No newline at end of file
...@@ -174,12 +174,9 @@ ...@@ -174,12 +174,9 @@
174 align-items: center; 174 align-items: center;
175 gap: 0.25rem; 175 gap: 0.25rem;
176 padding: 6px; 176 padding: 6px;
177 - background: rgba(40, 40, 38, 0.7); 177 + background: transparent;
178 - backdrop-filter: blur(20px) saturate(180%); 178 + border: none;
179 - -webkit-backdrop-filter: blur(20px) saturate(180%);
180 - border: 1px solid rgba(255, 255, 255, 0.1);
181 border-radius: 14px; 179 border-radius: 14px;
182 - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
183 } 180 }
184 181
185 .navbar-desktop { 182 .navbar-desktop {
...@@ -189,19 +186,15 @@ ...@@ -189,19 +186,15 @@
189 right: 1.5rem; 186 right: 1.5rem;
190 z-index: 110; 187 z-index: 110;
191 padding: 6px; 188 padding: 6px;
192 - background: rgba(40, 40, 38, 0.7); 189 + background: transparent;
193 - backdrop-filter: blur(20px) saturate(180%); 190 + border: none;
194 - -webkit-backdrop-filter: blur(20px) saturate(180%);
195 - border: 1px solid rgba(255, 255, 255, 0.1);
196 border-radius: 14px; 191 border-radius: 14px;
197 - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
198 } 192 }
199 193
200 :global(html:not(.dark)) .navbar-mobile, 194 :global(html:not(.dark)) .navbar-mobile,
201 :global(html:not(.dark)) .navbar-desktop { 195 :global(html:not(.dark)) .navbar-desktop {
202 - background: rgba(255, 255, 255, 0.7); 196 + background: transparent;
203 - border-color: rgba(0, 0, 0, 0.08); 197 + border: none;
204 - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
205 } 198 }
206 199
207 .navbar-desktop-inner { 200 .navbar-desktop-inner {
......
...@@ -10,8 +10,8 @@ ...@@ -10,8 +10,8 @@
10 10
11 let { children } = $props(); 11 let { children } = $props();
12 12
13 - // Don't show navbar on landing page 13 + // Don't show navbar on landing pages
14 - let isLanding = $derived($page.url.pathname === '/'); 14 + let isLanding = $derived($page.url.pathname === '/' || $page.url.pathname === '/nuevo-buscador');
15 15
16 // Global search modal state 16 // Global search modal state
17 let searchModalOpen = $state(false); 17 let searchModalOpen = $state(false);
......
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 - import { goto } from '$app/navigation';
4 - import { supabase } from '$lib/supabase';
5 - import {
6 - query,
7 - results,
8 - isLoading,
9 - indexLoaded,
10 - error,
11 - selectedIndex,
12 - showResults,
13 - initIndex,
14 - performSearch,
15 - clearSearch,
16 - navigateResults
17 - } from '$lib/stores/searchStore';
18 -
19 - // Pre-warm Supabase connection (evita cold start al navegar)
20 - async function warmupSupabase() {
21 - console.time('[WARMUP] Supabase');
22 - await supabase.schema('ppto').from('vista_objeto_estado').select('objeto').limit(1);
23 - console.timeEnd('[WARMUP] Supabase');
24 - }
25 3
4 + // Estado base
26 let mounted = false; 5 let mounted = false;
27 - let mode = 'archivo';
28 let searchFocused = false; 6 let searchFocused = false;
29 let searchVal = ''; 7 let searchVal = '';
30 - let hDiario = false;
31 - let hArchivo = false;
32 - let hVis = null;
33 - let hHist = null;
34 - let hWidget = null;
35 - let hGear = false;
36 - let mobileSettingsOpen = false;
37 - let gearMenuOpen = false;
38 let searchInput; 8 let searchInput;
39 - let debounceTimer; 9 + let isLoading = false;
40 - let autoFocused = false; 10 + let results = null;
41 - 11 + let error = null;
42 - // Search filters - which types to include 12 +
43 - let searchFilters = { 13 + // Ordenamiento
44 - entidad: true, 14 + let sortBy = 'amount';
45 - objeto_gasto: true, 15 + let sortOrder = 'desc';
46 - rubro: true 16 +
47 - }; 17 + // Paginación
18 + let currentPage = 1;
19 + let isLoadingMore = false;
20 + let allHits = [];
21 +
22 + // Tracking para evitar re-animaciones
23 + let seenHitIds = new Set();
24 + let lastSearchKey = '';
25 +
26 + function getHitKey(hit, index) {
27 + const doc = hit.document;
28 + const m = parseMetadatos(doc.metadatos);
29 +
30 + // Key basado en contenido + índice como tiebreaker para evitar duplicados
31 + if (doc.is_class === true || doc.is_class === 'true') {
32 + return `c-${m.entidad || 'x'}-${doc.devengado || 0}-${index}`;
33 + }
34 + return `p-${m.entidad || 0}-${m.programa || 0}-${m.proyecto || 0}-${doc.gestion || 0}-${index}`;
35 + }
48 36
49 - // Filtered results based on selected filters 37 + function isNewHit(hit) {
50 - $: filteredResults = $results.filter(item => searchFilters[item.tipo]); 38 + const key = getHitKey(hit);
39 + if (seenHitIds.has(key)) return false;
40 + seenHitIds.add(key);
41 + return true;
42 + }
51 43
52 - function toggleFilter(tipo) { 44 + function resetSeenHits() {
53 - searchFilters[tipo] = !searchFilters[tipo]; 45 + seenHitIds = new Set();
54 - searchFilters = searchFilters; // trigger reactivity
55 } 46 }
56 47
57 - function toggleGearMenu() { 48 + function toggleSort(field) {
58 - gearMenuOpen = !gearMenuOpen; 49 + if (sortBy === field) {
50 + sortOrder = sortOrder === 'desc' ? 'asc' : 'desc';
51 + } else {
52 + sortBy = field;
53 + sortOrder = 'desc';
54 + }
59 } 55 }
60 56
61 - onMount(() => { 57 + $: sortedHits = allHits.length > 0 ? [...allHits].sort((a, b) => {
62 - setTimeout(() => { mounted = true; }, 80); 58 + let valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
63 - initIndex(); 59 + let valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
64 - // Pre-calentar Supabase en background para evitar cold start 60 + return sortOrder === 'desc' ? valB - valA : valA - valB;
65 - warmupSupabase(); 61 + }) : [];
66 - 62 +
67 - // Cerrar gear menu al hacer click fuera 63 + // ¿Es búsqueda de entidades?
68 - const handleClickOutside = (e) => { 64 + $: isEntidadSearchActive = results?.hits?.[0]?.document?.class_ === 'entidad';
69 - if (gearMenuOpen && !e.target.closest('.gear-wrap')) { 65 +
70 - gearMenuOpen = false; 66 + // Agrupar por subarea cuando es búsqueda de entidades
67 + $: groupedHits = (() => {
68 + if (!isEntidadSearchActive || allHits.length === 0) return null;
69 +
70 + const groups = {};
71 + allHits.forEach(hit => {
72 + const m = parseMetadatos(hit.document.metadatos);
73 + const subarea = extractAfterHyphen(m.entidad_desc_subarea) || 'Otros';
74 + if (!groups[subarea]) {
75 + groups[subarea] = { name: subarea, hits: [], totalMonto: 0 };
71 } 76 }
72 - }; 77 + groups[subarea].hits.push(hit);
73 - document.addEventListener('click', handleClickOutside); 78 + groups[subarea].totalMonto += hit.document.devengado || 0;
74 - return () => document.removeEventListener('click', handleClickOutside); 79 + });
75 - }); 80 +
81 + // Ordenar items dentro de cada grupo
82 + Object.values(groups).forEach(group => {
83 + group.hits.sort((a, b) => {
84 + const valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
85 + const valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
86 + return sortOrder === 'desc' ? valB - valA : valA - valB;
87 + });
88 + });
76 89
77 - // Auto-focus en el buscador cuando el índice esté listo (solo desktop) 90 + // Convertir a array y ordenar grupos por monto total
78 - $: if ($indexLoaded && searchInput && !autoFocused) { 91 + return Object.values(groups).sort((a, b) => b.totalMonto - a.totalMonto);
79 - autoFocused = true; 92 + })();
80 - // No auto-focus en móvil para evitar que aparezca el teclado
81 - const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
82 - if (!isMobile) {
83 - setTimeout(() => {
84 - searchInput?.focus();
85 - }, 50);
86 - }
87 - }
88 93
89 - function toggleMobileSettings() { 94 + $: hasMoreResults = results && allHits.length < results.found;
90 - mobileSettingsOpen = !mobileSettingsOpen;
91 - }
92 95
93 - $: isD = mode === 'diario'; 96 + // Modo de búsqueda
94 - $: accent = isD ? '#4A8B6E' : '#C9A751'; 97 + let searchMode = 'gastos'; // 'gastos' | 'ingresos'
95 - $: placeholder = isD 98 + let searchSubmode = 'programas'; // 'programas' | 'clasificadores'
96 - ? 'Busca una entidad, un ministerio, un municipio...' 99 + let selectedClassifiers = [];
97 - : 'Busca instituciones, gastos, ingresos, sectores...';
98 - $: examples = isD
99 - ? [
100 - { label: 'MEFP', bg: 'rgba(140,170,150,0.10)', color: '#9ab8a8' },
101 - { label: 'Min. Educación', bg: 'rgba(150,155,175,0.10)', color: '#a0a5b8' },
102 - { label: 'GOB. La Paz', bg: 'rgba(180,130,100,0.10)', color: '#b8947a' },
103 - ]
104 - : [
105 - { label: 'Sueldos', bg: 'rgba(201,167,81,0.12)', color: '#d4b962', href: '/objeto/11700' },
106 - { label: 'Regalías', bg: 'rgba(160,150,170,0.09)', color: '#aaa0b0', href: '/rubro/14000' },
107 - { label: 'BOA', bg: 'rgba(150,155,175,0.10)', color: '#a0a5b8' },
108 - { label: 'MEFP', bg: 'rgba(140,170,150,0.10)', color: '#9ab8a8' },
109 - { label: 'Publicidad', bg: 'rgba(180,130,100,0.10)', color: '#b8947a' },
110 - ];
111 -
112 - function switchMode(m) {
113 - mode = m;
114 - searchVal = '';
115 - clearSearch();
116 - if (searchInput) searchInput.value = '';
117 - }
118 100
119 - function handleBlur() { 101 + const CLASIFICADORES_GASTOS = [
120 - setTimeout(() => { searchFocused = false; }, 200); 102 + { id: 'finfun', label: 'Finalidad y función' },
121 - } 103 + { id: 'objeto', label: 'Objetos de gasto' },
104 + { id: 'acteco', label: 'Actividad económica' },
105 + { id: 'entidad', label: 'Entidades' },
106 + { id: 'geografico', label: 'Geográfico', disabled: true },
107 + ];
122 108
123 - function handleInput(e) { 109 + const CLASIFICADORES_INGRESOS = [
124 - const value = e.target.value; 110 + { id: 'rubro', label: 'Rubro', disabled: true },
125 - searchVal = value; 111 + { id: 'organismo', label: 'Organismo', disabled: true },
126 - clearTimeout(debounceTimer); 112 + { id: 'fuente', label: 'Fuente', disabled: true },
127 - debounceTimer = setTimeout(() => { 113 + { id: 'entidad', label: 'Entidades' },
128 - performSearch(value); 114 + { id: 'geografico',label: 'Geográfico', disabled: true },
129 - }, 250); 115 + ];
130 - }
131 116
132 - function handleKeydown(e) { 117 + $: activeClasificadores = searchMode === 'gastos' ? CLASIFICADORES_GASTOS : CLASIFICADORES_INGRESOS;
133 - if (!$showResults) return; 118 +
134 - switch (e.key) { 119 + function toggleClassifier(id) {
135 - case 'ArrowDown': 120 + if (selectedClassifiers.includes(id)) {
136 - e.preventDefault(); 121 + selectedClassifiers = selectedClassifiers.filter(c => c !== id);
137 - navigateResults('down', filteredResults.length); 122 + } else {
138 - break; 123 + selectedClassifiers = [...selectedClassifiers, id];
139 - case 'ArrowUp':
140 - e.preventDefault();
141 - navigateResults('up', filteredResults.length);
142 - break;
143 - case 'Enter':
144 - e.preventDefault();
145 - // Si hay un resultado seleccionado, navegar a él
146 - if ($selectedIndex >= 0 && filteredResults[$selectedIndex]) {
147 - handleSelect(filteredResults[$selectedIndex]);
148 - } else if (filteredResults.length > 0) {
149 - // Si no hay selección pero hay resultados, ir al primero
150 - handleSelect(filteredResults[0]);
151 - }
152 - break;
153 - case 'Escape':
154 - clearSearch();
155 - searchVal = '';
156 - if (searchInput) searchInput.value = '';
157 - searchInput?.blur();
158 - break;
159 } 124 }
160 } 125 }
161 126
162 - function handleSelect(item) { 127 + function setMode(mode) {
163 - if (item.tipo === 'entidad') { 128 + searchMode = mode;
164 - goto(`/entidad/${item.codigo}`); 129 + selectedClassifiers = [];
165 - } else if (item.tipo === 'objeto_gasto') { 130 + if (mode === 'gastos') {
166 - goto(`/objeto/${item.codigo}`); 131 + searchSubmode = 'programas';
167 - } else if (item.tipo === 'rubro') { 132 + } else {
168 - goto(`/rubro/${item.codigo}`); 133 + searchSubmode = 'clasificadores';
169 } 134 }
170 - clearSearch();
171 - searchVal = '';
172 - if (searchInput) searchInput.value = '';
173 } 135 }
174 136
175 - function getTypeLabel(tipo) { 137 + function setSubmode(sub) {
176 - switch (tipo) { 138 + searchSubmode = sub;
177 - case 'entidad': return 'ENTIDAD'; 139 + selectedClassifiers = [];
178 - case 'objeto_gasto': return 'OBJETO'; 140 + }
179 - case 'rubro': return 'RUBRO'; 141 +
180 - default: return tipo?.toUpperCase() || ''; 142 + // Placeholder dinámico
143 + $: placeholderText = getPlaceholderText(searchMode, searchSubmode, selectedClassifiers);
144 +
145 + function getPlaceholderText(mode, submode, classifiers) {
146 + if (mode === 'gastos') {
147 + if (submode === 'programas') return 'Buscar gastos en programas y proyectos...';
148 + if (classifiers.length === 0) return 'Selecciona al menos un clasificador...';
149 + const nombres = classifiers.map(id => {
150 + const clf = CLASIFICADORES_GASTOS.find(c => c.id === id);
151 + return clf?.label?.toLowerCase() || id;
152 + }).join(', ');
153 + return `Buscar gastos en ${nombres}...`;
154 + } else {
155 + if (classifiers.length === 0) return 'Selecciona al menos un clasificador...';
156 + const nombres = classifiers.map(id => {
157 + const clf = CLASIFICADORES_INGRESOS.find(c => c.id === id);
158 + return clf?.label?.toLowerCase() || id;
159 + }).join(', ');
160 + return `Buscar ingresos en ${nombres}...`;
181 } 161 }
182 } 162 }
183 163
184 - function getTypeColor(tipo) { 164 + // Debounce
185 - switch (tipo) { 165 + let debounceTimer;
186 - case 'entidad': return '#4A8B6E'; 166 + let hintTimer;
187 - case 'objeto_gasto': return '#C9A751'; 167 + let showMinLengthHint = false;
188 - case 'rubro': return '#8B6E9B'; 168 + const DEBOUNCE_MS = 450;
189 - default: return '#6B6860'; 169 + const HINT_DELAY_MS = 1500;
170 +
171 + const POBLACION = 12000000;
172 + const API_BASE = '/api/search';
173 +
174 + // Hint diario
175 + let dailyData = null;
176 + let dailyLoading = true;
177 +
178 + async function fetchDaily() {
179 + try {
180 + const res = await fetch('/api/daily-summary');
181 + if (res.ok) dailyData = await res.json();
182 + } catch {
183 + // silencioso — es un hint, no crítico
184 + } finally {
185 + dailyLoading = false;
190 } 186 }
191 } 187 }
192 188
189 + // Ejemplos
190 + const EXAMPLES_PROGRAMAS = [
191 + { label: 'Vía férrea Bulo Bulo', color: 'rgba(232,146,124,0.22)', textColor: '#E8927C' },
192 + { label: 'Siderúrgica del Mutún', color: 'rgba(160,165,185,0.18)', textColor: '#B8BDCC' },
193 + ];
194 +
195 + const EXAMPLES_ENTIDADES = [
196 + { label: 'Fondo Indígena', color: 'rgba(126,184,216,0.22)', textColor: '#8EC8E8' },
197 + { label: 'YPFB', color: 'rgba(168,196,160,0.22)', textColor: '#B8D4B0' },
198 + ];
199 +
200 + // Ejemplos dinámicos según selección
201 + $: currentExamples = (selectedClassifiers.includes('entidad') && selectedClassifiers.length === 1)
202 + ? EXAMPLES_ENTIDADES
203 + : EXAMPLES_PROGRAMAS;
204 +
205 + // Datos para secciones
193 const WIDGETS_INGRESOS = [ 206 const WIDGETS_INGRESOS = [
194 { id: 'rubros', label: 'tipos de ingreso', d: 'Impuestos, regalías, ventas, créditos', t: 'Recursos por rubros', n: '324', href: '/clasificadores/rubros' }, 207 { id: 'rubros', label: 'tipos de ingreso', d: 'Impuestos, regalías, ventas, créditos', t: 'Recursos por rubros', n: '324', href: '/clasificadores/rubros' },
195 - { id: 'fuentes', label: 'fuentes de financiamiento', d: 'TGN, crédito externo, donaciones', t: 'Fuentes', n: '22', href: '/clasificadores/fuente' }, 208 + { id: 'fuentes', label: 'fuentes de financiamiento', d: 'TGN, crédito externo, donaciones', t: 'Fuentes', n: '22', href: '/clasificadores/fuentes' },
196 { id: 'organismos', label: 'organismos financiadores', d: 'BID, Banco Mundial, cooperación', t: 'Organismos', n: '228', href: '/clasificadores/organismos' }, 209 { id: 'organismos', label: 'organismos financiadores', d: 'BID, Banco Mundial, cooperación', t: 'Organismos', n: '228', href: '/clasificadores/organismos' },
197 ]; 210 ];
198 211
...@@ -201,25 +214,27 @@ ...@@ -201,25 +214,27 @@
201 { id: 'sector', label: 'sectores económicos', d: 'Industria, construcción, servicios', t: 'Sectores', n: '488', href: '/clasificadores/sectores' }, 214 { id: 'sector', label: 'sectores económicos', d: 'Industria, construcción, servicios', t: 'Sectores', n: '488', href: '/clasificadores/sectores' },
202 ]; 215 ];
203 216
204 - const WIDGETS_GASTOS_BOTTOM = [
205 - { id: 'finalidad', label: 'finalidades y funciones', d: 'Educación, salud, defensa', t: 'Finalidad', n: '132', href: '/clasificadores/funcional' },
206 - { id: 'donde', label: 'ubicaciones geográficas', d: 'Departamentos, municipios', t: 'Geográfico', n: '352', href: '/clasificadores/geografico' },
207 - ];
208 -
209 const HISTORIAS = [ 217 const HISTORIAS = [
210 - { 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 }, 218 + { 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 },
211 { color: '#C44B3F', title: 'Salud vs. Defensa: 20 años', desc: 'Cómo se redistribuyó el presupuesto entre sectores.', tag: 'COMPARATIVA', tall: false }, 219 { color: '#C44B3F', title: 'Salud vs. Defensa: 20 años', desc: 'Cómo se redistribuyó el presupuesto entre sectores.', tag: 'COMPARATIVA', tall: false },
212 - { 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 }, 220 + { 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 },
213 { 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 }, 221 { 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 },
214 ]; 222 ];
215 223
216 const VISUALIZACIONES = [ 224 const VISUALIZACIONES = [
217 - { 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 }, 225 + { color: '#4A8B6E', title: 'Evolución del gasto en salud', desc: 'Cómo cambiaron las prioridades.', type: 'Serie temporal', href: null },
218 - { color: '#C9A751', title: '¿En qué se gasta?', desc: 'Explora el presupuesto por objeto de gasto.', type: 'Treemap', href: '/clasificadores/objeto-gasto?modo=mapa' }, 226 + { color: '#C9A751', title: '¿En qué se gasta?', desc: 'Explora el presupuesto por objeto de gasto.', type: 'Treemap', href: '/clasificadores/objeto-gasto' },
219 - { color: '#8B6E9B', title: 'Mapa de inversión por departamento', desc: 'Distribución geográfica de la inversión pública.', type: 'Mapa', href: null }, 227 + { color: '#8B6E9B', title: 'Mapa de inversión', desc: 'Distribución geográfica de la inversión pública.', type: 'Mapa', href: null },
220 ]; 228 ];
221 229
222 - /* Reveal action */ 230 + // Hover states para secciones
231 + let hDiario = false;
232 + let hArchivo = false;
233 + let hWidget = null;
234 + let hHist = null;
235 + let hVis = null;
236 +
237 + // Reveal animation
223 let revealStates = {}; 238 let revealStates = {};
224 function reveal(node, { id, delay = 0 }) { 239 function reveal(node, { id, delay = 0 }) {
225 revealStates[id] = false; 240 revealStates[id] = false;
...@@ -234,7 +249,7 @@ ...@@ -234,7 +249,7 @@
234 return { destroy() { obs.disconnect(); } }; 249 return { destroy() { obs.disconnect(); } };
235 } 250 }
236 251
237 - /* Section navigation */ 252 + // Navegación interna - scroll a secciones
238 const SECTIONS_NAV = [ 253 const SECTIONS_NAV = [
239 { id: 'hero', label: 'Inicio' }, 254 { id: 'hero', label: 'Inicio' },
240 { id: 'datos', label: 'Datos' }, 255 { id: 'datos', label: 'Datos' },
...@@ -246,28 +261,231 @@ ...@@ -246,28 +261,231 @@
246 ]; 261 ];
247 let currentSection = 'hero'; 262 let currentSection = 'hero';
248 263
264 + function scrollToSection(id) {
265 + const el = document.querySelector(`[data-section="${id}"]`);
266 + if (el) el.scrollIntoView({ behavior: 'smooth' });
267 + }
268 +
269 + // Observer para actualizar sección activa
270 + let sectionObserver;
249 onMount(() => { 271 onMount(() => {
272 + setTimeout(() => { mounted = true; }, 80);
273 + fetchDaily();
274 +
275 + const isMobile = window.innerWidth < 768;
276 + if (!isMobile) setTimeout(() => searchInput?.focus(), 300);
277 +
278 + // IntersectionObserver para secciones
250 const sections = document.querySelectorAll('[data-section]'); 279 const sections = document.querySelectorAll('[data-section]');
251 - const observer = new IntersectionObserver((entries) => { 280 + sectionObserver = new IntersectionObserver((entries) => {
252 entries.forEach(entry => { 281 entries.forEach(entry => {
253 if (entry.isIntersecting) { 282 if (entry.isIntersecting) {
254 currentSection = entry.target.dataset.section; 283 currentSection = entry.target.dataset.section;
255 } 284 }
256 }); 285 });
257 - }, { threshold: 0.5 }); 286 + }, { threshold: 0.3 });
258 - sections.forEach(section => observer.observe(section)); 287 + sections.forEach(section => sectionObserver.observe(section));
259 - return () => observer.disconnect(); 288 +
289 + return () => sectionObserver?.disconnect();
260 }); 290 });
261 291
262 - function scrollToSection(id) { 292 + function searchExample(label) {
263 - const el = document.querySelector(`[data-section="${id}"]`); 293 + searchVal = label;
264 - if (el) el.scrollIntoView({ behavior: 'smooth' }); 294 + performSearch(label);
295 + searchInput?.focus();
296 + }
297 +
298 + async function handleInput(e) {
299 + searchVal = e.target.value;
300 + clearTimeout(debounceTimer);
301 + clearTimeout(hintTimer);
302 + showMinLengthHint = false;
303 +
304 + if (searchVal.length < 3) {
305 + results = null;
306 + error = null;
307 + if (searchVal.length > 0) {
308 + hintTimer = setTimeout(() => { showMinLengthHint = true; }, HINT_DELAY_MS);
309 + }
310 + return;
311 + }
312 +
313 + const needsClassifiers = searchMode === 'ingresos' ||
314 + (searchMode === 'gastos' && searchSubmode === 'clasificadores');
315 + if (needsClassifiers && selectedClassifiers.length === 0) {
316 + results = null;
317 + error = null;
318 + return;
319 + }
320 +
321 + debounceTimer = setTimeout(() => performSearch(searchVal), DEBOUNCE_MS);
322 + }
323 +
324 + async function performSearch(query, page = 1) {
325 + // NO borrar allHits inmediatamente - mantener resultados anteriores visibles
326 + if (page === 1) {
327 + isLoading = true;
328 + // Solo resetear sort en nueva búsqueda
329 + sortBy = 'amount';
330 + sortOrder = 'desc';
331 + } else {
332 + isLoadingMore = true;
333 + }
334 + error = null;
335 + currentPage = page;
336 +
337 + try {
338 + const isClassSearch = searchMode === 'ingresos' ||
339 + (searchMode === 'gastos' && searchSubmode === 'clasificadores');
340 + const params = new URLSearchParams({
341 + q: query,
342 + is_class: isClassSearch ? 'true' : 'false',
343 + mode: searchMode,
344 + page: page.toString()
345 + });
346 + // Para clasificadores, pedir más resultados para tener conteos precisos
347 + if (isClassSearch) {
348 + params.set('per_page', '250');
349 + }
350 + if (isClassSearch && selectedClassifiers.length > 0) {
351 + params.set('class_', selectedClassifiers.join(','));
352 + }
353 +
354 + console.log('[Search]', `${API_BASE}?${params}`);
355 + const res = await fetch(`${API_BASE}?${params}`);
356 + if (!res.ok) throw new Error(`Error ${res.status}`);
357 + const data = await res.json();
358 +
359 + // Actualizar resultados DESPUÉS de recibir datos (no antes)
360 + results = data;
361 + if (page === 1) {
362 + resetSeenHits();
363 + allHits = data.hits;
364 + } else {
365 + allHits = [...allHits, ...data.hits];
366 + }
367 + } catch (err) {
368 + console.error(err);
369 + error = 'Error al buscar. Intenta de nuevo.';
370 + if (page === 1) { results = null; allHits = []; }
371 + }
372 +
373 + isLoading = false;
374 + isLoadingMore = false;
375 + }
376 +
377 + function loadMore() {
378 + if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
265 } 379 }
380 +
381 + function handleKeydown(e) {
382 + if (e.key === 'Escape') { clearSearch(); searchInput?.blur(); }
383 + }
384 +
385 + function clearSearch() {
386 + searchVal = '';
387 + results = null;
388 + allHits = [];
389 + currentPage = 1;
390 + error = null;
391 + showMinLengthHint = false;
392 + clearTimeout(debounceTimer);
393 + clearTimeout(hintTimer);
394 + searchInput?.focus();
395 + }
396 +
397 + // Helpers
398 + function extractAfterHyphen(text) {
399 + if (!text) return '';
400 + const parts = text.split(' - ');
401 + return parts.length > 1 ? parts.slice(1).join(' - ').trim() : text;
402 + }
403 +
404 + function parseMetadatos(meta) {
405 + if (!meta) return {};
406 + if (typeof meta === 'object') return meta;
407 + try { return JSON.parse(meta); } catch { return {}; }
408 + }
409 +
410 + // Usar allHits para stats (no solo la última página)
411 + function getStats(hits, totalFound) {
412 + if (!hits?.length) return null;
413 + const isClassifier = hits[0]?.document?.is_class === true;
414 + const isEntidad = hits[0]?.document?.class_ === 'entidad';
415 + const montoTotal = hits.reduce((s, h) => s + (h.document.devengado || 0), 0);
416 +
417 + if (isClassifier) {
418 + const entidadesSet = new Set();
419 + hits.forEach(h => {
420 + const m = parseMetadatos(h.document.metadatos);
421 + if (m.entidad) entidadesSet.add(m.entidad);
422 + });
423 + return { found: totalFound, montoTotal, perCapita: montoTotal / POBLACION,
424 + numEntidades: entidadesSet.size,
425 + isClassifier: true, isEntidad, classType: hits[0]?.document?.class_ || null };
426 + } else {
427 + const entidadesSet = new Set();
428 + hits.forEach(h => { const m = parseMetadatos(h.document.metadatos); if (m.entidad) entidadesSet.add(m.entidad); });
429 + const years = hits.map(h => h.document.gestion).filter(Boolean);
430 + const minYear = Math.min(...years);
431 + const maxYear = Math.max(...years);
432 + return { found: totalFound, montoTotal, perCapita: montoTotal / POBLACION,
433 + numEntidades: entidadesSet.size,
434 + yearRange: minYear === maxYear ? `${minYear}` : `${minYear} – ${maxYear}`,
435 + isClassifier: false };
436 + }
437 + }
438 +
439 + function formatMonto(num) {
440 + if (num >= 1e9) return `${(num/1e9).toFixed(1)} mil millones de Bs`;
441 + if (num >= 1e6) return `${(num/1e6).toFixed(1)} millones de Bs`;
442 + if (num >= 1e3) return `${(num/1e3).toFixed(0)} mil Bs`;
443 + return `${num.toFixed(0)} Bs`;
444 + }
445 +
446 + function formatMontoFull(num) {
447 + return `${num.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} Bs`;
448 + }
449 +
450 + function generateCodigo(hit) {
451 + const m = parseMetadatos(hit.document.metadatos);
452 + return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`;
453 + }
454 +
455 + $: stats = allHits.length > 0 ? getStats(allHits, results?.found || allHits.length) : null;
456 +
457 + $: {
458 + const _mode = searchMode;
459 + const _submode = searchSubmode;
460 + const _classifiers = selectedClassifiers;
461 + if (searchVal.length >= 3 && mounted) {
462 + clearTimeout(debounceTimer);
463 + const needsClassifiers = _mode === 'ingresos' || (_mode === 'gastos' && _submode === 'clasificadores');
464 + if (!needsClassifiers || _classifiers.length > 0) {
465 + debounceTimer = setTimeout(() => performSearch(searchVal), 150);
466 + } else {
467 + // No hay clasificadores seleccionados - limpiar resultados
468 + results = null;
469 + allHits = [];
470 + }
471 + }
472 + }
473 +
474 + // ¿Mostrar chips de clasificadores?
475 + $: showClassifierChips = searchMode === 'ingresos' ||
476 + (searchMode === 'gastos' && searchSubmode === 'clasificadores');
477 +
478 + // ¿Hay búsqueda activa?
479 + $: isSearching = searchVal.length >= 3;
266 </script> 480 </script>
267 481
482 +<svelte:head>
483 + <title>Buscador | Presupuesto Abierto</title>
484 +</svelte:head>
485 +
268 <div class="page" class:mounted> 486 <div class="page" class:mounted>
269 487
270 - <!-- Section Navigation Dots --> 488 + <!-- Dots de navegación -->
271 <nav class="section-dots"> 489 <nav class="section-dots">
272 {#each SECTIONS_NAV as s} 490 {#each SECTIONS_NAV as s}
273 <button 491 <button
...@@ -281,233 +499,365 @@ ...@@ -281,233 +499,365 @@
281 {/each} 499 {/each}
282 </nav> 500 </nav>
283 501
284 - <!-- ═══ LANDING SCREEN (NAV + HERO) ═══ --> 502 + <!-- Hero -->
285 - <section class="landing-screen" data-section="hero"> 503 + <main data-section="hero">
286 - <nav>
287 - <div class="nav-logo"><img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" /></div>
288 - </nav>
289 504
290 - <!-- ═══ HERO ═══ --> 505 + <div class="nav-logo">
291 - <section class="hero"> 506 + <img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
292 - <div class="dot-pattern"></div> 507 + </div>
293 - <div class="hero-inner">
294 - <!-- Bloque 1: Identidad -->
295 - <div class="hero-identity">
296 - <h1 class="hero-title anim" style="--d:200ms">Presupuesto <span class="gold">Abierto</span></h1>
297 - <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>
298 - <div class="hero-stats anim" style="--d:420ms">
299 - <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>
300 - </div>
301 - </div>
302 508
303 - <!-- Bloque 2: Búsqueda --> 509 + <button class="nav-menu-btn" aria-label="Menú">
304 - <div class="hero-search anim" style="--d:520ms"> 510 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
305 - <div class="search-wrap"> 511 + <line x1="3" y1="6" x2="21" y2="6"/>
306 - <!-- Search bar unificado --> 512 + <line x1="3" y1="12" x2="21" y2="12"/>
307 - <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}"> 513 + <line x1="3" y1="18" x2="21" y2="18"/>
308 - <!-- Link al diario + Badge informativo (desktop) --> 514 + </svg>
309 - <div class="search-meta toggle-desktop"> 515 + </button>
310 - <a href="/vista-diaria" class="search-meta-item search-diario-link"> 516 +
311 - Ver gasto de hoy 517 + <div class="search-container anim" style="--d:200ms">
312 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 518 +
313 - <line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/> 519 + <!-- Título -->
314 - </svg> 520 + <h1 class="hero-title" class:hero-collapsed={allHits.length > 0}>
315 - </a> 521 + Presupuesto <span class="gold">Abierto</span>
316 - <span class="search-meta-item search-badge"> 522 + </h1>
317 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 523 + <p class="hero-sub" class:hero-collapsed={allHits.length > 0}>
318 - <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"/> 524 + Información histórica y diaria de todo el Estado Boliviano.
319 - </svg> 525 + </p>
320 - Viendo 2005 – 2025 526 +
321 - </span> 527 + <!-- Buscador -->
322 - </div> 528 + <div class="search-wrap" class:search-wrap-with-results={allHits.length > 0}>
323 - <div class="search-divider"></div> 529 + <div class="search-bar" class:search-focused={searchFocused} class:has-results={isSearching}>
324 - 530 + <svg class="search-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
325 - <!-- Mobile: mode indicator + settings button --> 531 + stroke={searchFocused ? '#D4B254' : '#8C8880'} stroke-width="2" stroke-linecap="round">
326 - <button class="mobile-settings-btn" class:mobile-settings-open={mobileSettingsOpen} on:click={toggleMobileSettings}> 532 + <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
327 - <span class="mobile-mode-indicator" style="background:{accent}"></span> 533 + </svg>
328 - <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"> 534 +
329 - <circle cx="12" cy="12" r="3"/> 535 + <div class="search-input-wrap">
330 - <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"/> 536 + <input
331 - </svg> 537 + type="text"
332 - </button> 538 + bind:this={searchInput}
333 - 539 + bind:value={searchVal}
334 - <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"> 540 + on:input={handleInput}
335 - <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/> 541 + on:keydown={handleKeydown}
336 - </svg> 542 + on:focus={() => searchFocused = true}
337 - <div class="search-input-wrap"> 543 + on:blur={() => searchFocused = false}
338 - <input 544 + placeholder=""
339 - type="text" 545 + />
340 - bind:this={searchInput} 546 + {#if !searchVal}
341 - bind:value={searchVal} 547 + <div class="search-placeholder">
342 - on:input={handleInput} 548 + <span class="blink-cursor" class:cursor-hidden={searchFocused}></span>
343 - on:keydown={handleKeydown} 549 + <span class="placeholder-text">{placeholderText}</span>
344 - on:focus={() => searchFocused = true} 550 + </div>
345 - on:blur={handleBlur} 551 + {/if}
346 - disabled={!$indexLoaded} 552 + {#if searchVal && !isLoading}
347 - /> 553 + <button class="search-clear" on:click={clearSearch}>
348 - {#if !searchVal} 554 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
349 - <div class="search-placeholder"> 555 + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
350 - <span class="blink-cursor" class:cursor-diario={isD} class:cursor-archivo={!isD}></span>
351 - <span class="placeholder-text placeholder-desktop">{placeholder}</span>
352 - <span class="placeholder-text placeholder-mobile">{isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...'}</span>
353 - </div>
354 - {/if}
355 - {#if searchVal && !$isLoading}
356 - <button class="search-clear" on:click={() => { clearSearch(); searchVal = ''; if (searchInput) searchInput.value = ''; }}>
357 - <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>
358 - </button>
359 - {/if}
360 - </div>
361 - <!-- Gear button with filter dropdown (desktop) -->
362 - <div class="gear-wrap gear-desktop" on:click|stopPropagation>
363 - <button class="gear-btn" class:gear-active={gearMenuOpen} on:click={toggleGearMenu}>
364 - <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">
365 - <circle cx="12" cy="12" r="3"/>
366 - <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"/>
367 </svg> 556 </svg>
368 </button> 557 </button>
369 - {#if gearMenuOpen} 558 + {/if}
370 - <div class="gear-menu"> 559 + {#if isLoading}
371 - <div class="gear-menu-title">Ajusta tu búsqueda</div> 560 + <div class="search-loading"><span class="loading-dot"></span></div>
372 - <button class="gear-row" on:click={() => toggleFilter('entidad')}> 561 + {/if}
373 - <div class="gear-check" class:gear-check-on={searchFilters.entidad}>
374 - {#if searchFilters.entidad}
375 - <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>
376 - {/if}
377 - </div>
378 - <div class="gear-row-content">
379 - <span class="gear-row-question">¿Quién?</span>
380 - <span class="gear-row-label" class:gear-row-label-on={searchFilters.entidad}>Instituciones</span>
381 - </div>
382 - <span class="gear-row-count">756</span>
383 - </button>
384 - <button class="gear-row" on:click={() => toggleFilter('objeto_gasto')}>
385 - <div class="gear-check" class:gear-check-on={searchFilters.objeto_gasto}>
386 - {#if searchFilters.objeto_gasto}
387 - <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>
388 - {/if}
389 - </div>
390 - <div class="gear-row-content">
391 - <span class="gear-row-question">¿En qué?</span>
392 - <span class="gear-row-label" class:gear-row-label-on={searchFilters.objeto_gasto}>Objetos de gasto</span>
393 - </div>
394 - <span class="gear-row-count">324</span>
395 - </button>
396 - <button class="gear-row" on:click={() => toggleFilter('rubro')}>
397 - <div class="gear-check" class:gear-check-on={searchFilters.rubro}>
398 - {#if searchFilters.rubro}
399 - <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>
400 - {/if}
401 - </div>
402 - <div class="gear-row-content">
403 - <span class="gear-row-question">¿De dónde?</span>
404 - <span class="gear-row-label" class:gear-row-label-on={searchFilters.rubro}>Rubros de ingreso</span>
405 - </div>
406 - <span class="gear-row-count">324</span>
407 - </button>
408 - </div>
409 - {/if}
410 - </div>
411 </div> 562 </div>
563 + </div>
412 564
413 - <!-- Search Results Dropdown --> 565 + <!-- ─── FILTROS INLINE ─── -->
414 - {#if $isLoading && $query.length >= 2} 566 + <div class="filters-row" class:filters-hidden={isSearching}>
415 - <div class="search-results"> 567 +
416 - <div class="search-loading">Buscando...</div> 568 + <!-- Nivel 1: Segmented control Gastos / Ingresos -->
569 + <div class="filter-level filter-level-1">
570 + <div class="segmented-control">
571 + <button
572 + class="segment"
573 + class:segment-active={searchMode === 'gastos'}
574 + on:click={() => setMode('gastos')}
575 + >
576 + Gastos
577 + </button>
578 + <button
579 + class="segment"
580 + class:segment-active={searchMode === 'ingresos'}
581 + on:click={() => setMode('ingresos')}
582 + >
583 + Ingresos
584 + </button>
417 </div> 585 </div>
418 - {:else if searchFocused && filteredResults.length > 0} 586 +
419 - <div class="search-results"> 587 + <!-- Nivel 2: Segmented control Programas / Clasificadores (solo Gastos) -->
420 - {#each filteredResults as item, i} 588 + {#if searchMode === 'gastos'}
589 + <div class="segmented-control segmented-control-secondary">
421 <button 590 <button
422 - type="button" 591 + class="segment segment-secondary"
423 - class="search-result-item" 592 + class:segment-active={searchSubmode === 'programas'}
424 - class:result-selected={$selectedIndex === i} 593 + on:click={() => setSubmode('programas')}
425 - on:click={() => handleSelect(item)}
426 > 594 >
427 - <div class="result-main"> 595 + Programas y proyectos
428 - <span class="result-name">{item.nombre}</span>
429 - {#if item.sigla}
430 - <span class="result-sigla">({item.sigla})</span>
431 - {/if}
432 - </div>
433 - <div class="result-meta">
434 - {#if item.codigo}
435 - <span class="result-code">{item.codigo}</span>
436 - {/if}
437 - <span class="result-type" style="color:{getTypeColor(item.tipo)}">{getTypeLabel(item.tipo)}</span>
438 - </div>
439 </button> 596 </button>
440 - {/each} 597 + <button
441 - </div> 598 + class="segment segment-secondary"
442 - {:else if searchFocused && $query.length >= 2 && filteredResults.length === 0} 599 + class:segment-active={searchSubmode === 'clasificadores'}
443 - <div class="search-results"> 600 + on:click={() => setSubmode('clasificadores')}
444 - <div class="search-no-results">Sin resultados para "{$query}"</div> 601 + >
602 + Clasificadores
603 + </button>
604 + </div>
605 + {/if}
606 + </div>
607 +
608 + <!-- Nivel 3: Clasificadores con checkboxes (cuando aplica) -->
609 + {#if showClassifierChips}
610 + <div class="filter-level filter-level-2">
611 + <span class="filter-level-label">Selecciona clasificadores:</span>
612 + <div class="checkbox-group">
613 + {#each activeClasificadores as clf}
614 + <button
615 + class="checkbox-chip"
616 + class:checkbox-chip-active={selectedClassifiers.includes(clf.id)}
617 + class:checkbox-chip-disabled={clf.disabled}
618 + on:click={() => { if (!clf.disabled) toggleClassifier(clf.id); }}
619 + disabled={clf.disabled}
620 + >
621 + <span class="checkbox-box">
622 + {#if selectedClassifiers.includes(clf.id)}
623 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
624 + <polyline points="20 6 9 17 4 12"/>
625 + </svg>
626 + {/if}
627 + </span>
628 + <span class="checkbox-label">{clf.label}</span>
629 + {#if clf.disabled}
630 + <span class="chip-soon">próx.</span>
631 + {/if}
632 + </button>
633 + {/each}
634 + </div>
445 </div> 635 </div>
446 {/if} 636 {/if}
637 + </div>
447 638
448 - {#if $error} 639 + <!-- ─── ÁREA DE RESULTADOS / EJEMPLOS / HINT ─── -->
449 - <div class="search-error">{$error}</div> 640 + <div class="results-area">
450 - {/if} 641 +
642 + <!-- Ejemplos -->
643 + <div class="examples" class:examples-hidden={isSearching}>
644 + <span class="examples-label">Ej.:</span>
645 + {#each currentExamples as ex}
646 + <button
647 + class="example-chip"
648 + style="background:{ex.color}; color:{ex.textColor}"
649 + on:click={() => searchExample(ex.label)}
650 + >
651 + {ex.label}
652 + </button>
653 + {/each}
654 + </div>
451 655
452 - <!-- Mobile settings panel --> 656 + <!-- Hint diario -->
453 - <div class="mobile-settings-panel" class:mobile-settings-panel-open={mobileSettingsOpen}> 657 + {#if !isSearching && !dailyLoading}
454 - <div class="mobile-meta-bar"> 658 + <a
455 - <a href="/vista-diaria" class="mobile-meta-item mobile-diario-link"> 659 + href="/vista-diaria"
456 - Ver hoy 660 + class="daily-hint"
457 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 661 + class:daily-hint-visible={!isSearching}
458 - <line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/> 662 + >
459 - </svg> 663 + <span class="daily-dot"></span>
460 - </a> 664 + <span class="daily-text">
461 - <span class="mobile-meta-item mobile-search-badge"> 665 + {#if dailyData}
462 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 666 + Ayer el Estado ejecutó <strong>{formatMonto(dailyData.monto)}</strong>
463 - <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"/> 667 + {:else}
464 - </svg> 668 + Ver el resumen de ejecución de ayer
465 - Viendo 2005 – 2025 669 + {/if}
466 </span> 670 </span>
671 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
672 + <path d="M5 12h14M12 5l7 7-7 7"/>
673 + </svg>
674 + </a>
675 + {/if}
676 +
677 + <!-- Estados de búsqueda (mensajes) -->
678 + {#if showMinLengthHint && searchVal.length > 0 && searchVal.length < 3}
679 + <div class="results-message">
680 + <div class="results-hint">Escribe al menos 3 letras para buscar</div>
467 </div> 681 </div>
468 - <div class="mobile-filter-section"> 682 + {:else if error && !results}
469 - <div class="mobile-filter-title">Buscar</div> 683 + <div class="results-message">
470 - <div class="mobile-filter-bar"> 684 + <div class="results-error">{error}</div>
471 - <button class="mobile-filter-chip" class:filter-chip-active={searchFilters.entidad} on:click={() => toggleFilter('entidad')}>
472 - <span class="filter-chip-dot" style="background:#4A8B6E"></span>
473 - Instituciones
474 - </button>
475 - <button class="mobile-filter-chip" class:filter-chip-active={searchFilters.objeto_gasto} on:click={() => toggleFilter('objeto_gasto')}>
476 - <span class="filter-chip-dot" style="background:#C9A751"></span>
477 - Gastos
478 - </button>
479 - <button class="mobile-filter-chip" class:filter-chip-active={searchFilters.rubro} on:click={() => toggleFilter('rubro')}>
480 - <span class="filter-chip-dot" style="background:#8B6E9B"></span>
481 - Ingresos
482 - </button>
483 - </div>
484 </div> 685 </div>
485 - </div> 686 + {:else if isLoading && !results}
687 + <div class="results-message">
688 + <div class="results-loading">Buscando...</div>
689 + </div>
690 + {:else if results && results.hits?.length === 0}
691 + <div class="results-message">
692 + <div class="results-empty">Sin resultados para "{searchVal}"</div>
693 + </div>
694 + {/if}
695 +
696 + <!-- Contenedor de resultados (siempre montado para evitar flicker) -->
697 + <div
698 + class="results-content results-content-filled"
699 + class:results-content-visible={allHits.length > 0}
700 + >
701 + {#if stats}
702 + <div class="results-summary">
703 + <div class="summary-row">
704 + <div class="summary-left">
705 + <div class="summary-header">
706 + <span class="summary-count">{sortedHits.length}</span>
707 + <span class="summary-header-text">de {stats.found.toLocaleString()} resultados</span>
708 + {#if isLoading}<span class="updating-dot"></span>{/if}
709 + </div>
710 + <div class="summary-meta">
711 + <span class="summary-tag summary-tag-gold">
712 + {formatMonto(stats.montoTotal)}
713 + </span>
714 + {#if stats.isClassifier}
715 + {#if stats.isEntidad && groupedHits}
716 + <span class="summary-tag summary-tag-entities">{stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
717 + <span class="summary-tag">{groupedHits.length} {groupedHits.length === 1 ? 'categoría' : 'categorías'}</span>
718 + {/if}
719 + {:else}
720 + <span class="summary-tag">en {stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
721 + {#if stats.yearRange}
722 + <span class="summary-tag">{stats.yearRange}</span>
723 + {/if}
724 + {/if}
725 + </div>
726 + </div>
727 + <div class="sort-buttons">
728 + {#if !stats.isClassifier}
729 + <button class="sort-btn" class:sort-btn-active={sortBy === 'year'} on:click={() => toggleSort('year')}>
730 + Año
731 + {#if sortBy === 'year'}
732 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
733 + {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
734 + {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
735 + </svg>
736 + {/if}
737 + </button>
738 + {/if}
739 + <button class="sort-btn" class:sort-btn-active={sortBy === 'amount'} on:click={() => toggleSort('amount')}>
740 + Monto
741 + {#if sortBy === 'amount'}
742 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
743 + {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
744 + {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
745 + </svg>
746 + {/if}
747 + </button>
748 + </div>
749 + </div>
750 + </div>
751 + {/if}
752 +
753 + <div class="results-list">
754 + {#if groupedHits}
755 + <!-- Vista agrupada para entidades -->
756 + {#each groupedHits as group, gi}
757 + <div class="result-group">
758 + <div class="result-group-header">
759 + <span class="result-group-name">{group.name}</span>
760 + <span class="result-group-count">{group.hits.length}</span>
761 + <span class="result-group-monto">{formatMonto(group.totalMonto)}</span>
762 + </div>
763 + <div class="result-group-items">
764 + {#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))}
765 + {@const meta = parseMetadatos(hit.document.metadatos)}
766 + {@const isDA = meta.da && meta.entidad_desc_entidad}
767 + {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
768 + <a href="/entidad/{meta.entidad}" class="result-card result-card-grouped">
769 + <div class="result-content">
770 + <div class="result-text">
771 + {#if hit.highlights?.[0]?.snippet}
772 + {@html hit.highlights[0].snippet}
773 + {:else}
774 + {hit.document.texto}
775 + {/if}
776 + </div>
777 + <div class="result-meta">
778 + {#if isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if}
779 + <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
780 + </div>
781 + </div>
782 + </a>
783 + {/each}
784 + </div>
785 + </div>
786 + {/each}
787 + {:else}
788 + <!-- Vista plana para otros tipos -->
789 + {#each sortedHits as hit, i (getHitKey(hit, i))}
790 + {@const meta = parseMetadatos(hit.document.metadatos)}
791 + {@const isEntidadClass = hit.document.class_ === 'entidad'}
792 + {@const isDA = isEntidadClass && meta.da && meta.entidad_desc_entidad}
793 + {@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
794 + {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
795 + {@const isClassResult = hit.document.is_class === true}
796 + <a
797 + href={isEntidadClass
798 + ? `/entidad/${meta.entidad}`
799 + : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
800 + class="result-card"
801 + >
802 + <div class="result-content">
803 + <div class="result-text">
804 + {#if hit.highlights?.[0]?.snippet}
805 + {@html hit.highlights[0].snippet}
806 + {:else}
807 + {hit.document.texto}
808 + {/if}
809 + </div>
810 + <div class="result-meta">
811 + {#if isEntidadClass}
812 + {#if isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if}
813 + {#if subarea}<span class="result-subarea">{subarea}</span>{/if}
814 + {:else if isClassResult}
815 + <span class="result-class-type">{hit.document.class_}</span>
816 + {:else}
817 + <span class="result-year">{hit.document.gestion}</span>
818 + {/if}
819 + <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
820 + </div>
821 + </div>
822 + <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">
823 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
824 + <polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
825 + </svg>
826 + </a>
827 + {/each}
828 + {/if}
829 + </div>
486 830
487 - <!-- Pills --> 831 + {#if hasMoreResults}
488 - <div class="pills"> 832 + <div class="load-more-container">
489 - <span class="pills-label">Ej:</span> 833 + <button class="load-more-btn" on:click={loadMore} disabled={isLoadingMore}>
490 - {#each examples as ex} 834 + {#if isLoadingMore}
491 - {#if ex.href} 835 + <span class="loading-dot"></span> Cargando...
492 - <a class="pill" style="color:{ex.color};background:{ex.bg}" href={ex.href}>{ex.label}</a> 836 + {:else}
493 - {:else} 837 + Cargar más ({allHits.length} de {results.found})
494 - <button class="pill" style="color:{ex.color};background:{ex.bg}" on:click={() => searchVal = ex.label}>{ex.label}</button> 838 + {/if}
839 + </button>
840 + </div>
495 {/if} 841 {/if}
496 - {/each}
497 </div> 842 </div>
843 +
844 + <div class="area-spacer" class:area-spacer-hidden={allHits.length > 0}></div>
498 </div> 845 </div>
499 </div> 846 </div>
500 </div> 847 </div>
501 848
502 <!-- Scroll hint --> 849 <!-- Scroll hint -->
503 - <div class="scroll-hint anim" style="--d:700ms" class:scroll-hint-hidden={searchFocused && $results.length > 0}> 850 + <div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching}>
504 - <svg width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="#B8B5AD" stroke-width="1.2" stroke-linecap="round"> 851 + <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">
505 <rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/> 852 <rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
506 </svg> 853 </svg>
507 - <span>Explora el portal</span> 854 + <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">
855 + <path d="M6 9l6 6 6-6"/>
856 + </svg>
857 + <span>Explora la página</span>
508 </div> 858 </div>
509 - </section> 859 +
510 - </section> 860 + </main>
511 861
512 <!-- ═══ PORTAL DIRECTORY ═══ --> 862 <!-- ═══ PORTAL DIRECTORY ═══ -->
513 <section class="directory" data-section="datos"> 863 <section class="directory" data-section="datos">
...@@ -905,7 +1255,7 @@ ...@@ -905,7 +1255,7 @@
905 .section-dot-active:hover{transform:scale(1.2)} 1255 .section-dot-active:hover{transform:scale(1.2)}
906 .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} 1256 .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}
907 .section-dot:hover .section-dot-label{opacity:1} 1257 .section-dot:hover .section-dot-label{opacity:1}
908 - .gold{color:#C9A751} 1258 + .gold{color:#F0C14D}
909 .container{max-width:1060px;margin:0 auto} 1259 .container{max-width:1060px;margin:0 auto}
910 1260
911 /* Reveal */ 1261 /* Reveal */
...@@ -932,6 +1282,7 @@ ...@@ -932,6 +1282,7 @@
932 .nav-logo img{height:100%;width:auto;object-fit:contain} 1282 .nav-logo img{height:100%;width:auto;object-fit:contain}
933 1283
934 /* ── HERO ── */ 1284 /* ── HERO ── */
1285 + 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}
935 .landing-screen{height:100vh;background:#1C1C1A;display:flex;flex-direction:column;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden} 1286 .landing-screen{height:100vh;background:#1C1C1A;display:flex;flex-direction:column;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden}
936 .hero{background:#1C1C1A;color:#F5F0E8;flex:1;display:flex;flex-direction:column;justify-content:center;padding:40px 48px 32px;position:relative;overflow:hidden} 1287 .hero{background:#1C1C1A;color:#F5F0E8;flex:1;display:flex;flex-direction:column;justify-content:center;padding:40px 48px 32px;position:relative;overflow:hidden}
937 .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} 1288 .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 @@ ...@@ -941,7 +1292,7 @@
941 .hero-search{display:flex;justify-content:center;position:relative;z-index:50} 1292 .hero-search{display:flex;justify-content:center;position:relative;z-index:50}
942 .hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD} 1293 .hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD}
943 .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} 1294 .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}
944 - .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} 1295 + .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}
945 .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} 1296 .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}
946 .hero-sub-link:hover{color:#4A8B6E;border-bottom-color:#4A8B6E} 1297 .hero-sub-link:hover{color:#4A8B6E;border-bottom-color:#4A8B6E}
947 .hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1} 1298 .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 @@ ...@@ -951,16 +1302,17 @@
951 .stat-link:hover{color:#C9A751;border-bottom-color:#C9A751} 1302 .stat-link:hover{color:#C9A751;border-bottom-color:#C9A751}
952 1303
953 /* Search */ 1304 /* Search */
954 - .search-wrap{max-width:960px;width:100%;margin:0 auto;position:relative;z-index:9999} 1305 + .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)}
1306 + .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}
955 .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} 1307 .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}
956 - .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)} 1308 + .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)}
957 .search-has-results{border-radius:16px 16px 0 0} 1309 .search-has-results{border-radius:16px 16px 0 0}
958 .search-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;padding:0 4px} 1310 .search-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;padding:0 4px}
959 .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} 1311 .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}
960 .search-meta-item svg{display:block;flex-shrink:0} 1312 .search-meta-item svg{display:block;flex-shrink:0}
961 - .search-badge{color:#C9A751;background:rgba(201,167,81,0.12)} 1313 + .search-badge{color:#4A8B6E;background:rgba(74,139,110,0.12)}
962 - .search-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)} 1314 + .search-diario-link{color:#C9A751;background:rgba(201,167,81,0.12)}
963 - .search-diario-link:hover{background:rgba(74,139,110,0.22);color:#5AA080} 1315 + .search-diario-link:hover{background:rgba(201,167,81,0.22);color:#D4B65E}
964 .search-diario-link svg{transition:transform 0.2s} 1316 .search-diario-link svg{transition:transform 0.2s}
965 .search-diario-link:hover svg{transform:translateX(3px)} 1317 .search-diario-link:hover svg{transform:translateX(3px)}
966 .search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0} 1318 .search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0}
...@@ -968,9 +1320,11 @@ ...@@ -968,9 +1320,11 @@
968 .search-input-wrap{flex:1;position:relative;display:flex;align-items:center} 1320 .search-input-wrap{flex:1;position:relative;display:flex;align-items:center}
969 .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} 1321 .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}
970 .search-placeholder{position:absolute;left:10px;top:50%;transform:translateY(-50%);display:flex;align-items:center;pointer-events:none} 1322 .search-placeholder{position:absolute;left:10px;top:50%;transform:translateY(-50%);display:flex;align-items:center;pointer-events:none}
971 - .blink-cursor{width:1.5px;height:18px;margin-right:1px;animation:blink 1s step-end infinite;border-radius:1px;flex-shrink:0} 1323 + .blink-cursor{width:1.5px;height:18px;margin-right:1px;animation:blink 1s step-end infinite;border-radius:1px;flex-shrink:0;background:#C9A751}
1324 + .cursor-hidden{opacity:0}
972 .cursor-diario{background:#4A8B6E} 1325 .cursor-diario{background:#4A8B6E}
973 .cursor-archivo{background:#C9A751} 1326 .cursor-archivo{background:#C9A751}
1327 + .has-results{border-radius:14px;box-shadow:none;background:rgba(255,255,255,0.03)}
974 .placeholder-text{font-family:var(--sans);font-size:16px;color:#B8B5AD} 1328 .placeholder-text{font-family:var(--sans);font-size:16px;color:#B8B5AD}
975 .placeholder-mobile{display:none} 1329 .placeholder-mobile{display:none}
976 .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} 1330 .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 @@ ...@@ -1003,9 +1357,9 @@
1003 .mobile-meta-bar{display:flex;gap:10px;padding:4px 0} 1357 .mobile-meta-bar{display:flex;gap:10px;padding:4px 0}
1004 .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} 1358 .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}
1005 .mobile-meta-item svg{flex-shrink:0} 1359 .mobile-meta-item svg{flex-shrink:0}
1006 - .mobile-search-badge{color:#C9A751;background:rgba(201,167,81,0.12)} 1360 + .mobile-search-badge{color:#4A8B6E;background:rgba(74,139,110,0.12)}
1007 - .mobile-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)} 1361 + .mobile-diario-link{color:#C9A751;background:rgba(201,167,81,0.12)}
1008 - .mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(74,139,110,0.22)} 1362 + .mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(201,167,81,0.22)}
1009 .mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)} 1363 .mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)}
1010 .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} 1364 .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}
1011 .mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap} 1365 .mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap}
...@@ -1041,9 +1395,138 @@ ...@@ -1041,9 +1395,138 @@
1041 .pill:hover::before{opacity:1} 1395 .pill:hover::before{opacity:1}
1042 .pill:hover{transform:translateY(-1px)} 1396 .pill:hover{transform:translateY(-1px)}
1043 1397
1044 - .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} 1398 + .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}
1045 .scroll-hint-hidden{opacity:0;pointer-events:none} 1399 .scroll-hint-hidden{opacity:0;pointer-events:none}
1046 .scroll-hint span{font-family:var(--mono);font-size:10px;letter-spacing:0.2em;color:#B8B5AD;opacity:0.7} 1400 .scroll-hint span{font-family:var(--mono);font-size:10px;letter-spacing:0.2em;color:#B8B5AD;opacity:0.7}
1401 + .scroll-icon-mouse{display:block}
1402 + .scroll-icon-touch{display:none}
1403 +
1404 + /* ── NEW SEARCH STYLES ── */
1405 + .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}
1406 + .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}
1407 + .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}
1408 + .hero-collapsed{opacity:0;height:0;margin:0;overflow:hidden;pointer-events:none}
1409 + .cursor-hidden{opacity:0}
1410 +
1411 + /* Filters */
1412 + .filters-row{display:flex;flex-direction:column;gap:16px;margin-top:20px;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
1413 + .filters-hidden{opacity:0;height:0;margin:0;overflow:hidden;pointer-events:none}
1414 + .filter-level{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:center}
1415 + .filter-level-1{justify-content:center}
1416 + .filter-level-2{flex-direction:column;gap:8px}
1417 + .filter-level-label{font-family:var(--mono);font-size:11px;letter-spacing:0.08em;color:#9B9890}
1418 +
1419 + /* Segmented Control */
1420 + .segmented-control{display:inline-flex;background:rgba(255,255,255,0.06);border-radius:10px;padding:4px;gap:2px}
1421 + .segmented-control-secondary{background:rgba(255,255,255,0.04)}
1422 + .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}
1423 + .segment:hover{color:#B8B5AD}
1424 + .segment-active{background:rgba(255,255,255,0.12);color:#F5F0E8}
1425 + .segment-secondary{font-size:13px;padding:8px 16px}
1426 +
1427 + /* Checkbox Chips */
1428 + .checkbox-group{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}
1429 + .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}
1430 + .checkbox-chip:hover{background:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.12)}
1431 + .checkbox-chip-active{background:rgba(74,139,110,0.15);border-color:rgba(74,139,110,0.4);color:#5AAF8A}
1432 + .checkbox-chip-disabled{opacity:0.4;cursor:not-allowed}
1433 + .checkbox-chip-disabled:hover{background:rgba(255,255,255,0.04);border-color:rgba(255,255,255,0.08)}
1434 + .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}
1435 + .checkbox-chip-active .checkbox-box{background:#4A8B6E;border-color:#4A8B6E}
1436 + .checkbox-label{line-height:1}
1437 + .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}
1438 +
1439 + /* Results Area */
1440 + .results-area{width:100%;margin-top:24px;position:relative;min-height:60px;text-align:center}
1441 + .area-spacer{height:100px;transition:height 0.3s}
1442 + .area-spacer-hidden{height:0}
1443 +
1444 + /* Examples */
1445 + .examples{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:center;transition:all 0.3s}
1446 + .examples-hidden{opacity:0;height:0;overflow:hidden;pointer-events:none}
1447 + .examples-label{font-family:var(--mono);font-size:11px;letter-spacing:0.08em;color:#9B9890}
1448 + .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}
1449 + .example-chip:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}
1450 +
1451 + /* Daily Hint */
1452 + .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}
1453 + .daily-hint-visible{opacity:1;transform:translateY(0)}
1454 + .daily-hint:hover{background:rgba(74,139,110,0.08);border-color:rgba(74,139,110,0.4)}
1455 + .daily-dot{width:6px;height:6px;border-radius:50%;background:#4A8B6E;animation:pulse 2s ease-in-out infinite;flex-shrink:0}
1456 + .daily-text{font-family:var(--sans);font-size:12px;color:#9B9890}
1457 + .daily-text strong{color:#4A8B6E}
1458 + .daily-hint svg{color:#4A8B6E;opacity:0.6;transition:all 0.2s}
1459 + .daily-hint:hover svg{opacity:1;transform:translateX(3px)}
1460 +
1461 + /* Results Messages */
1462 + .results-message{text-align:center;padding:20px}
1463 + .results-hint,.results-loading{font-family:var(--sans);font-size:14px;color:#6B6860}
1464 + .results-error{font-family:var(--sans);font-size:14px;color:#C44B3F}
1465 + .results-empty{font-family:var(--sans);font-size:14px;color:#8B8880}
1466 +
1467 + /* Results Content */
1468 + .results-content{max-height:0;overflow:hidden;opacity:0;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)}
1469 + .results-content-visible{max-height:60vh;overflow-y:auto;opacity:1;background:transparent;border-radius:0;border:none;padding:14px 10px;text-align:left}
1470 +
1471 + /* Results Summary */
1472 + .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)}
1473 + .summary-row{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap}
1474 + .summary-left{display:flex;flex-direction:column;gap:8px}
1475 + .summary-header{display:flex;align-items:center;gap:8px}
1476 + .summary-count{font-family:var(--mono);font-size:24px;font-weight:600;color:#F0C14D}
1477 + .summary-header-text{font-family:var(--sans);font-size:14px;color:#A5A29A}
1478 + .updating-dot{width:6px;height:6px;border-radius:50%;background:#C9A751;animation:pulse 1s ease-in-out infinite}
1479 + .summary-meta{display:flex;gap:8px;flex-wrap:wrap}
1480 + .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}
1481 + .summary-tag-gold{background:rgba(240,193,77,0.18);color:#F0C14D}
1482 + .summary-tag-entities{background:rgba(74,139,110,0.12);color:#4A8B6E}
1483 +
1484 + /* Sort Buttons */
1485 + .sort-buttons{display:flex;gap:8px}
1486 + .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}
1487 + .sort-btn:hover{background:rgba(255,255,255,0.08);color:#B8B5AD}
1488 + .sort-btn-active{background:rgba(201,167,81,0.12);color:#C9A751}
1489 +
1490 + /* Results List */
1491 + .results-list{display:flex;flex-direction:column;gap:0}
1492 +
1493 + /* Result Group (for entidades) */
1494 + .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}
1495 + .result-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
1496 + .result-group-header{display:flex;align-items:center;gap:12px;padding:8px 0;background:transparent}
1497 + .result-group-name{font-family:var(--sans);font-size:13px;font-weight:500;color:#F5F0E8;flex:1}
1498 + .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}
1499 + .result-group-monto{font-family:var(--mono);font-size:12px;color:#F0C14D}
1500 + .result-group-items{display:flex;flex-direction:column}
1501 +
1502 + /* Result Card */
1503 + .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)}
1504 + .result-card:last-child{border-bottom:none}
1505 + .result-card:hover{background:rgba(255,255,255,0.06)}
1506 + .result-card-grouped{border-radius:6px;border-bottom:none;padding:10px 12px;margin-left:8px}
1507 + .result-card-grouped:hover{background:rgba(255,255,255,0.03)}
1508 + .result-content{flex:1;min-width:0}
1509 + .result-text{font-family:var(--sans);font-size:14px;color:#F5F0E8;line-height:1.4}
1510 + .result-text :global(mark){background:rgba(240,193,77,0.85);color:#1C1C1A;border-radius:3px;padding:1px 5px;font-weight:600}
1511 + .result-meta{display:flex;align-items:center;gap:10px;margin-top:6px;flex-wrap:wrap}
1512 + .result-parent,.result-subarea,.result-class-type{font-family:var(--mono);font-size:11px;color:#8B8880}
1513 + .result-year{font-family:var(--mono);font-size:12px;color:#8B8880;background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:4px}
1514 + .result-monto{font-family:var(--mono);font-size:12px;color:#F0C14D;margin-left:auto}
1515 + .result-link-icon{color:#6B6860;opacity:0;transition:all 0.2s;flex-shrink:0}
1516 + .result-card:hover .result-link-icon{opacity:1}
1517 +
1518 + /* Load More */
1519 + .load-more-container{display:flex;justify-content:center;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.06)}
1520 + .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}
1521 + .load-more-btn:hover{background:rgba(255,255,255,0.08);color:#F5F0E8}
1522 + .load-more-btn:disabled{opacity:0.5;cursor:not-allowed}
1523 + .loading-dot{width:6px;height:6px;border-radius:50%;background:#C9A751;animation:pulse 1s ease-in-out infinite}
1524 + .search-loading{display:flex;align-items:center;justify-content:center}
1525 +
1526 + /* Nav elements in hero */
1527 + main[data-section="hero"] .nav-logo{position:absolute;top:24px;left:24px}
1528 + .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}
1529 + .nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
1047 1530
1048 /* ── DIRECTORY ── */ 1531 /* ── DIRECTORY ── */
1049 .directory{background:#f5f5f7;min-height:100vh;display:flex;align-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:48px} 1532 .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 @@ ...@@ -1222,7 +1705,7 @@
1222 ══════════════════════════════════════════════════════════════════════════ */ 1705 ══════════════════════════════════════════════════════════════════════════ */
1223 @media(max-width:768px){ 1706 @media(max-width:768px){
1224 .page{scroll-snap-type:y proximity} 1707 .page{scroll-snap-type:y proximity}
1225 - .landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal} 1708 + main[data-section="hero"],.landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal}
1226 1709
1227 /* Section dots - bottom horizontal bar */ 1710 /* Section dots - bottom horizontal bar */
1228 .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} 1711 .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 @@ ...@@ -1263,6 +1746,36 @@
1263 .pills{gap:6px} 1746 .pills{gap:6px}
1264 .pill{font-size:11px;padding:4px 10px} 1747 .pill{font-size:11px;padding:4px 10px}
1265 .scroll-hint{bottom:20px} 1748 .scroll-hint{bottom:20px}
1749 + .scroll-icon-mouse{display:none}
1750 + .scroll-icon-touch{display:block}
1751 +
1752 + /* New search responsive */
1753 + .search-container{padding:0 16px}
1754 + .hero-title{font-size:clamp(32px,7vw,48px);margin-bottom:4px}
1755 + .hero-sub{font-size:14px;margin-bottom:24px}
1756 + .filters-row{gap:12px;margin-top:16px}
1757 + .filter-level{flex-direction:column;gap:8px}
1758 + .segmented-control{width:100%;justify-content:center}
1759 + .segment{flex:1;text-align:center;padding:10px 12px;font-size:13px}
1760 + .checkbox-group{justify-content:flex-start}
1761 + .checkbox-chip{font-size:12px;padding:6px 12px}
1762 + .examples{flex-direction:row;gap:8px}
1763 + .examples-label{display:none}
1764 + .example-chip{font-size:12px;padding:6px 12px}
1765 + .daily-hint{padding:10px 14px;gap:8px}
1766 + .daily-text{font-size:13px}
1767 + .results-summary{padding:12px 14px}
1768 + .summary-row{flex-direction:column;gap:12px}
1769 + .summary-count{font-size:20px}
1770 + .summary-meta{flex-wrap:wrap}
1771 + .summary-tag{font-size:10px;padding:3px 8px}
1772 + .sort-buttons{width:100%;justify-content:flex-end}
1773 + .sort-btn{font-size:11px;padding:6px 10px}
1774 + .result-card{padding:12px 14px}
1775 + .result-text{font-size:13px}
1776 + .result-monto{font-size:11px}
1777 + .result-year{font-size:11px;padding:2px 6px}
1778 + .load-more-btn{font-size:13px;padding:10px 20px}
1266 1779
1267 /* Directory */ 1780 /* Directory */
1268 .directory{padding:48px 24px} 1781 .directory{padding:48px 24px}
...@@ -1351,6 +1864,34 @@ ...@@ -1351,6 +1864,34 @@
1351 .pill{font-size:10px;padding:4px 8px} 1864 .pill{font-size:10px;padding:4px 8px}
1352 .scroll-hint{display:none} 1865 .scroll-hint{display:none}
1353 1866
1867 + /* New search 480px */
1868 + .search-container{padding:0 12px}
1869 + .hero-title{font-size:clamp(28px,8vw,36px)}
1870 + .hero-sub{font-size:13px;margin-bottom:20px}
1871 + main[data-section="hero"] .nav-logo{top:16px;left:16px;height:60px}
1872 + .nav-menu-btn{top:16px;right:16px;width:40px;height:40px}
1873 + .segmented-control{padding:3px}
1874 + .segment{padding:8px 10px;font-size:12px}
1875 + .segment-secondary{font-size:11px;padding:6px 8px}
1876 + .filter-level-label{font-size:10px}
1877 + .checkbox-chip{font-size:11px;padding:5px 10px;gap:6px}
1878 + .checkbox-box{width:14px;height:14px}
1879 + .example-chip{font-size:11px;padding:5px 10px}
1880 + .daily-hint{padding:8px 12px;border-radius:10px}
1881 + .daily-dot{width:6px;height:6px}
1882 + .daily-text{font-size:12px}
1883 + .results-summary{padding:10px 12px;border-radius:10px}
1884 + .summary-count{font-size:18px}
1885 + .summary-header-text{font-size:12px}
1886 + .result-group-header{padding:10px 12px}
1887 + .result-group-name{font-size:12px}
1888 + .result-card{padding:10px 12px;border-radius:8px}
1889 + .result-text{font-size:12px}
1890 + .result-meta{gap:6px;margin-top:4px}
1891 + .result-parent,.result-subarea,.result-class-type{font-size:10px}
1892 + .result-monto{font-size:10px}
1893 + .load-more-btn{font-size:12px;padding:8px 16px;border-radius:8px}
1894 +
1354 /* Directory */ 1895 /* Directory */
1355 .directory{padding:40px 16px} 1896 .directory{padding:40px 16px}
1356 .directory-inner{gap:24px} 1897 .directory-inner{gap:24px}
...@@ -1417,4 +1958,4 @@ ...@@ -1417,4 +1958,4 @@
1417 .footer-org{font-size:9px;letter-spacing:0.08em} 1958 .footer-org{font-size:9px;letter-spacing:0.08em}
1418 .footer-right{font-size:9px} 1959 .footer-right{font-size:9px}
1419 } 1960 }
1420 -</style>
...\ No newline at end of file ...\ No newline at end of file
1961 +</style>
......
1 +// Proxy para evitar CORS
2 +const API_BASE = 'http://34.66.240.236/api/proyecto/search';
3 +
4 +export async function GET({ url }) {
5 + const q = url.searchParams.get('q') || '';
6 + const is_class = url.searchParams.get('is_class') || 'false';
7 + const mode = url.searchParams.get('mode') || 'gastos'; // 'gastos' | 'ingresos'
8 + const class_ = url.searchParams.get('class_') || ''; // comma-separated: entidad, objeto, finfun, acteco
9 + const page = url.searchParams.get('page') || '1'; // Paginación
10 + const per_page = url.searchParams.get('per_page') || ''; // Resultados por página
11 +
12 + // Build URL with all parameters
13 + const apiUrl = new URL(API_BASE);
14 + apiUrl.searchParams.set('q', q);
15 + apiUrl.searchParams.set('is_class', is_class);
16 + apiUrl.searchParams.set('page', page);
17 + if (per_page) apiUrl.searchParams.set('per_page', per_page);
18 + if (mode) apiUrl.searchParams.set('mode', mode);
19 + if (class_) apiUrl.searchParams.set('class_', class_);
20 +
21 + try {
22 + const response = await fetch(apiUrl.toString());
23 +
24 + if (!response.ok) {
25 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
26 + status: response.status,
27 + headers: { 'Content-Type': 'application/json' }
28 + });
29 + }
30 +
31 + const data = await response.json();
32 +
33 + return new Response(JSON.stringify(data), {
34 + headers: { 'Content-Type': 'application/json' }
35 + });
36 + } catch (err) {
37 + console.error('Proxy error:', err);
38 + return new Response(JSON.stringify({ error: 'Failed to fetch from API' }), {
39 + status: 500,
40 + headers: { 'Content-Type': 'application/json' }
41 + });
42 + }
43 +}
1 -me pare<script> 1 +<script>
2 import { supabase } from '$lib/supabase'; 2 import { supabase } from '$lib/supabase';
3 import { onMount, untrack } from 'svelte'; 3 import { onMount, untrack } from 'svelte';
4 import { page } from '$app/stores'; 4 import { page } from '$app/stores';
......
...@@ -87,16 +87,20 @@ ...@@ -87,16 +87,20 @@
87 let treemapHeight = $state(500); 87 let treemapHeight = $state(500);
88 let treemapContainer = $state(null); 88 let treemapContainer = $state(null);
89 let hoveredNode = $state(null); 89 let hoveredNode = $state(null);
90 + let pinnedNode = $state(null); // Nodo fijado para mobile (primer tap)
91 + let tooltipHovered = $state(false); // Para mantener tooltip visible al pasar mouse sobre él
92 + let isMobile = $state(false); // Detectar si es dispositivo táctil
90 let showTreemapHelp = $state(false); 93 let showTreemapHelp = $state(false);
91 let mapaSidebarOpen = $state(false); // Para abrir sidebar en móvil 94 let mapaSidebarOpen = $state(false); // Para abrir sidebar en móvil
92 let mapaSidebarCollapsed = $state(false); // Para colapsar sidebar en desktop 95 let mapaSidebarCollapsed = $state(false); // Para colapsar sidebar en desktop
93 let sidebarYearDropdownOpen = $state(false); // Dropdown de año en sidebar 96 let sidebarYearDropdownOpen = $state(false); // Dropdown de año en sidebar
94 97
95 // Selector de nivel para vista aplanada 98 // Selector de nivel para vista aplanada
96 - let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida' 99 + let treemapViewLevel = $state('partida'); // 'jerarquico' | 'grupo' | 'subgrupo' | 'partida' | 'subpartida'
97 let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias) 100 let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias)
98 const NIVEL_OPTIONS = [ 101 const NIVEL_OPTIONS = [
99 { value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' }, 102 { value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' },
103 + { value: 'grupo', label: 'Grupos', desc: '8-9 categorías' },
100 { value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' }, 104 { value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' },
101 { value: 'partida', label: 'Partidas', desc: '~100 categorías' }, 105 { value: 'partida', label: 'Partidas', desc: '~100 categorías' },
102 { value: 'subpartida', label: 'Subpartidas', desc: '~300 categorías' } 106 { value: 'subpartida', label: 'Subpartidas', desc: '~300 categorías' }
...@@ -409,7 +413,7 @@ ...@@ -409,7 +413,7 @@
409 const treemap = d3.treemap() 413 const treemap = d3.treemap()
410 .size([treemapWidth, treemapHeight]) 414 .size([treemapWidth, treemapHeight])
411 .paddingOuter(4) 415 .paddingOuter(4)
412 - .paddingTop(22) // Espacio para el título del grupo 416 + .paddingTop(treemapViewLevel === 'grupo' ? 4 : 22) // Solo espacio extra para título si no estamos en nivel grupo
413 .paddingInner(2) 417 .paddingInner(2)
414 .round(true); 418 .round(true);
415 419
...@@ -432,8 +436,8 @@ ...@@ -432,8 +436,8 @@
432 value: grupoNode.value 436 value: grupoNode.value
433 }); 437 });
434 438
435 - // Agregar los items del grupo 439 + // Agregar los items del grupo (solo si no estamos en nivel grupo)
436 - if (grupoNode.children) { 440 + if (grupoNode.children && treemapViewLevel !== 'grupo') {
437 grupoNode.children.forEach(itemNode => { 441 grupoNode.children.forEach(itemNode => {
438 nodes.push({ 442 nodes.push({
439 type: 'item', 443 type: 'item',
...@@ -543,6 +547,47 @@ ...@@ -543,6 +547,47 @@
543 calculateTreemapLayout(); 547 calculateTreemapLayout();
544 } 548 }
545 549
550 + // Navegar a vista de distribución por entidades
551 + function navigateToEntityDistribution(node) {
552 + // Obtener el código del objeto (puede ser grupo, subgrupo, partida o subpartida)
553 + const codigo = node.id || node.data?.objeto;
554 + if (codigo) {
555 + // Navegar a la ruta de distribución por entidades
556 + goto(`/objeto/${codigo}/entidades?gestion=${selectedYear}`);
557 + }
558 + }
559 +
560 + // Handler de click para nodos en vista plana (no jerárquica)
561 + function handleFlatNodeClick(node, e) {
562 + e.stopPropagation(); // Evitar que cierre el tooltip inmediatamente
563 + if (treemapViewLevel === 'jerarquico') return; // No aplica en modo jerárquico
564 +
565 + if (isMobile) {
566 + // Mobile: primer tap fija el tooltip, segundo tap (en botón) navega
567 + if (pinnedNode === node) {
568 + // Ya está fijado, no hacer nada (el botón maneja la navegación)
569 + return;
570 + }
571 + // Si hay otro nodo fijado, cambiar a este
572 + if (pinnedNode && pinnedNode !== node) {
573 + pinnedNode = node;
574 + hoveredNode = node;
575 + return;
576 + }
577 + // Fijar el tooltip en este nodo
578 + pinnedNode = node;
579 + hoveredNode = node;
580 + } else {
581 + // Desktop: click directo navega
582 + navigateToEntityDistribution(node);
583 + }
584 + }
585 +
586 + // Cerrar tooltip fijado
587 + function closePinnedTooltip() {
588 + pinnedNode = null;
589 + }
590 +
546 // Reactivo: recargar datos cuando cambia el año o nivel 591 // Reactivo: recargar datos cuando cambia el año o nivel
547 // Usar variable derivada para evitar loops en effects 592 // Usar variable derivada para evitar loops en effects
548 let treemapLoadTrigger = $derived(`${selectedYear}-${selectedEntity?.entidad ?? 0}-${treemapViewLevel}-${viewMode}-${showConsolidado}`); 593 let treemapLoadTrigger = $derived(`${selectedYear}-${selectedEntity?.entidad ?? 0}-${treemapViewLevel}-${viewMode}-${showConsolidado}`);
...@@ -720,6 +765,9 @@ ...@@ -720,6 +765,9 @@
720 }); 765 });
721 766
722 onMount(async () => { 767 onMount(async () => {
768 + // Detectar dispositivo táctil
769 + isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
770 +
723 // Forzar recálculo de layout para navegación cliente 771 // Forzar recálculo de layout para navegación cliente
724 // Esto evita problemas de renderizado cuando se navega desde otra página 772 // Esto evita problemas de renderizado cuando se navega desde otra página
725 requestAnimationFrame(() => { 773 requestAnimationFrame(() => {
...@@ -2222,6 +2270,7 @@ ...@@ -2222,6 +2270,7 @@
2222 width={treemapWidth} 2270 width={treemapWidth}
2223 height={treemapHeight} 2271 height={treemapHeight}
2224 class="block" 2272 class="block"
2273 + onclick={() => { if (pinnedNode) { pinnedNode = null; hoveredNode = null; } }}
2225 > 2274 >
2226 {#if treemapViewLevel === 'jerarquico'} 2275 {#if treemapViewLevel === 'jerarquico'}
2227 <!-- MODO JERÁRQUICO: con drill-down --> 2276 <!-- MODO JERÁRQUICO: con drill-down -->
...@@ -2314,40 +2363,90 @@ ...@@ -2314,40 +2363,90 @@
2314 {@const textColorSecondary = getTextColorSecondary(grupoId)} 2363 {@const textColorSecondary = getTextColorSecondary(grupoId)}
2315 2364
2316 {#if node.type === 'grupo'} 2365 {#if node.type === 'grupo'}
2317 - <!-- Contenedor de grupo con título --> 2366 + {#if treemapViewLevel === 'grupo'}
2318 - <g class="treemap-node" transform="translate({node.x0}, {node.y0})"> 2367 + <!-- Grupo como item principal (nivel grupo) -->
2319 - <!-- Fondo del grupo (sutil) --> 2368 + {@const pct = (node.value / flatTotalValue) * 100}
2320 - <rect 2369 + {@const area = width * height}
2321 - width={width} 2370 + {@const scale = Math.sqrt(area) / 12}
2322 - height={height} 2371 + {@const pctSize = Math.min(20, Math.max(9, scale * 1.5))}
2323 - fill={getNodeColor(grupoId, 0.08)} 2372 + {@const nameSize = Math.min(13, Math.max(8, scale * 0.95))}
2324 - stroke={getNodeColor(grupoId, 0.3)} 2373 + {@const valueSize = Math.min(11, Math.max(7, scale * 0.75))}
2325 - stroke-width="1" 2374 + <g
2326 - rx="3" 2375 + class="treemap-node"
2327 - /> 2376 + transform="translate({node.x0}, {node.y0})"
2328 - <!-- Título del grupo con fondo para legibilidad --> 2377 + onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
2329 - {#if width > 50} 2378 + onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }}
2330 - {@const labelText = width > 180 ? node.name : (width > 100 ? node.name.slice(0, 18) + (node.name.length > 18 ? '…' : '') : (width > 60 ? node.name.slice(0, 10) + '…' : 'G' + node.grupoCode))} 2379 + onclick={(e) => handleFlatNodeClick(node, e)}
2380 + style="cursor: pointer;"
2381 + >
2382 + <rect
2383 + width={width}
2384 + height={height}
2385 + fill={getNodeColor(grupoId, isHovered ? 1 : 0.85)}
2386 + stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
2387 + stroke-width={isHovered ? 2 : 0}
2388 + rx="2"
2389 + />
2390 +
2391 + {#if width > 35 && height > 22}
2392 + <foreignObject x="4" y="3" width={width - 8} height={height - 6}>
2393 + <div class="flex flex-col gap-0 overflow-hidden" style="font-family: var(--font-sans);">
2394 + <div class="font-semibold leading-none opacity-75" style="font-size: {pctSize}px; color: {textColor};">
2395 + {pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
2396 + </div>
2397 + {#if height > 40 && width > 50}
2398 + <div class="leading-tight mt-0.5 opacity-85" style="font-size: {nameSize}px; color: {textColor};">
2399 + {width > 90 ? node.name : node.name.slice(0, Math.floor(width / 6)) + (node.name.length > Math.floor(width / 6) ? '…' : '')}
2400 + </div>
2401 + {/if}
2402 + {#if height > 55 && width > 55}
2403 + <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2404 + {formatMoneyCompact(node.value)}
2405 + </div>
2406 + <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2407 + {formatPerCapita(node.value)}
2408 + </div>
2409 + {/if}
2410 + </div>
2411 + </foreignObject>
2412 + {/if}
2413 + </g>
2414 + {:else}
2415 + <!-- Contenedor de grupo con título (niveles subgrupo, partida, subpartida) -->
2416 + <g class="treemap-node" transform="translate({node.x0}, {node.y0})">
2417 + <!-- Fondo del grupo (sutil) -->
2331 <rect 2418 <rect
2332 - x="3" 2419 + width={width}
2333 - y="2" 2420 + height={height}
2334 - width={Math.min(labelText.length * 5.8 + 16, width - 6)} 2421 + fill={getNodeColor(grupoId, 0.08)}
2335 - height="16" 2422 + stroke={getNodeColor(grupoId, 0.3)}
2336 - fill="rgba(0,0,0,0.5)" 2423 + stroke-width="1"
2337 rx="3" 2424 rx="3"
2338 /> 2425 />
2339 - <text 2426 + <!-- Título del grupo con fondo para legibilidad -->
2340 - x="8" 2427 + {#if width > 50}
2341 - y="13" 2428 + {@const labelText = width > 180 ? node.name : (width > 100 ? node.name.slice(0, 18) + (node.name.length > 18 ? '…' : '') : (width > 60 ? node.name.slice(0, 10) + '…' : 'G' + node.grupoCode))}
2342 - fill="#ffffff" 2429 + <rect
2343 - font-size="10" 2430 + x="3"
2344 - font-weight="600" 2431 + y="2"
2345 - style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; text-transform: uppercase; letter-spacing: 0.5px;" 2432 + width={Math.min(labelText.length * 5.8 + 16, width - 6)}
2346 - > 2433 + height="16"
2347 - {labelText} 2434 + fill="rgba(0,0,0,0.5)"
2348 - </text> 2435 + rx="3"
2349 - {/if} 2436 + />
2350 - </g> 2437 + <text
2438 + x="8"
2439 + y="13"
2440 + fill="#ffffff"
2441 + font-size="10"
2442 + font-weight="600"
2443 + style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; text-transform: uppercase; letter-spacing: 0.5px;"
2444 + >
2445 + {labelText}
2446 + </text>
2447 + {/if}
2448 + </g>
2449 + {/if}
2351 {:else} 2450 {:else}
2352 <!-- Item individual --> 2451 <!-- Item individual -->
2353 {@const pct = (node.value / flatTotalValue) * 100} 2452 {@const pct = (node.value / flatTotalValue) * 100}
...@@ -2359,8 +2458,9 @@ ...@@ -2359,8 +2458,9 @@
2359 <g 2458 <g
2360 class="treemap-node" 2459 class="treemap-node"
2361 transform="translate({node.x0}, {node.y0})" 2460 transform="translate({node.x0}, {node.y0})"
2362 - onmouseenter={() => hoveredNode = node} 2461 + onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
2363 - onmouseleave={() => hoveredNode = null} 2462 + onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }}
2463 + onclick={(e) => handleFlatNodeClick(node, e)}
2364 style="cursor: pointer;" 2464 style="cursor: pointer;"
2365 > 2465 >
2366 <rect 2466 <rect
...@@ -2401,15 +2501,28 @@ ...@@ -2401,15 +2501,28 @@
2401 </svg> 2501 </svg>
2402 2502
2403 <!-- Tooltip flotante (frosted glass) --> 2503 <!-- Tooltip flotante (frosted glass) -->
2404 - {#if hoveredNode && hoveredNode.type !== 'grupo'} 2504 + {#if hoveredNode && (hoveredNode.type !== 'grupo' || treemapViewLevel === 'grupo')}
2405 {@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1} 2505 {@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
2506 + {@const isPinned = pinnedNode === hoveredNode}
2406 <div 2507 <div
2407 class="treemap-tooltip" 2508 class="treemap-tooltip"
2509 + class:pinned={isPinned}
2510 + class:has-cta={treemapViewLevel !== 'jerarquico'}
2408 style=" 2511 style="
2409 left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px; 2512 left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px;
2410 - top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px; 2513 + top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 140)}px;
2411 " 2514 "
2515 + onclick={(e) => e.stopPropagation()}
2516 + onmouseenter={() => { if (!isMobile && treemapViewLevel !== 'jerarquico') tooltipHovered = true; }}
2517 + onmouseleave={() => { tooltipHovered = false; hoveredNode = null; }}
2412 > 2518 >
2519 + {#if isPinned}
2520 + <button
2521 + class="tooltip-close"
2522 + onclick={() => { pinnedNode = null; hoveredNode = null; }}
2523 + aria-label="Cerrar"
2524 + >&times;</button>
2525 + {/if}
2413 {#if treemapViewLevel !== 'jerarquico'} 2526 {#if treemapViewLevel !== 'jerarquico'}
2414 <!-- Vista aplanada: mostrar contexto del grupo --> 2527 <!-- Vista aplanada: mostrar contexto del grupo -->
2415 <div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div> 2528 <div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
...@@ -2419,6 +2532,15 @@ ...@@ -2419,6 +2532,15 @@
2419 <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;"> 2532 <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2420 {((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total 2533 {((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total
2421 </div> 2534 </div>
2535 + <!-- CTA para ver distribución por entidades -->
2536 + <div class="tooltip-cta">
2537 + <button
2538 + class="tooltip-cta-btn"
2539 + onclick={() => navigateToEntityDistribution(hoveredNode)}
2540 + >
2541 + ¿Quién gasta más aquí? →
2542 + </button>
2543 + </div>
2422 {:else} 2544 {:else}
2423 <!-- Vista jerárquica --> 2545 <!-- Vista jerárquica -->
2424 <div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div> 2546 <div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div>
...@@ -4155,6 +4277,11 @@ ...@@ -4155,6 +4277,11 @@
4155 font-family: var(--font-sans); 4277 font-family: var(--font-sans);
4156 } 4278 }
4157 4279
4280 + .treemap-tooltip.pinned,
4281 + .treemap-tooltip.has-cta {
4282 + pointer-events: auto;
4283 + }
4284 +
4158 :global(html.dark) .treemap-tooltip { 4285 :global(html.dark) .treemap-tooltip {
4159 background: rgba(30, 30, 30, 0.75); 4286 background: rgba(30, 30, 30, 0.75);
4160 border: 1px solid rgba(255, 255, 255, 0.1); 4287 border: 1px solid rgba(255, 255, 255, 0.1);
...@@ -4162,6 +4289,60 @@ ...@@ -4162,6 +4289,60 @@
4162 color: #f5f5f5; 4289 color: #f5f5f5;
4163 } 4290 }
4164 4291
4292 + /* Tooltip close button (mobile) */
4293 + .tooltip-close {
4294 + position: absolute;
4295 + top: 4px;
4296 + right: 6px;
4297 + width: 24px;
4298 + height: 24px;
4299 + background: transparent;
4300 + border: none;
4301 + color: inherit;
4302 + opacity: 0.5;
4303 + font-size: 18px;
4304 + line-height: 1;
4305 + cursor: pointer;
4306 + padding: 0;
4307 + display: flex;
4308 + align-items: center;
4309 + justify-content: center;
4310 + }
4311 + .tooltip-close:hover {
4312 + opacity: 1;
4313 + }
4314 +
4315 + /* Tooltip CTA */
4316 + .tooltip-cta {
4317 + margin-top: 0.75rem;
4318 + padding-top: 0.625rem;
4319 + border-top: 1px solid rgba(0, 0, 0, 0.1);
4320 + }
4321 + :global(html.dark) .tooltip-cta {
4322 + border-top-color: rgba(255, 255, 255, 0.1);
4323 + }
4324 +
4325 + .tooltip-cta-btn {
4326 + width: 100%;
4327 + padding: 0.5rem 0.75rem;
4328 + background: var(--theme-accent);
4329 + color: white;
4330 + border: none;
4331 + border-radius: 6px;
4332 + font-size: 0.8125rem;
4333 + font-weight: 500;
4334 + cursor: pointer;
4335 + transition: background 0.15s ease, transform 0.1s ease;
4336 + text-align: left;
4337 + }
4338 + .tooltip-cta-btn:hover {
4339 + background: color-mix(in srgb, var(--theme-accent) 85%, black);
4340 + transform: translateX(2px);
4341 + }
4342 + .tooltip-cta-btn:active {
4343 + transform: scale(0.98);
4344 + }
4345 +
4165 /* Transiciones suaves para el treemap */ 4346 /* Transiciones suaves para el treemap */
4166 :global(.treemap-node) { 4347 :global(.treemap-node) {
4167 transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; 4348 transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
......
1 +<script>
2 + import { onMount } from 'svelte';
3 + import { goto } from '$app/navigation';
4 + import { supabase } from '$lib/supabase';
5 + import {
6 + query,
7 + results,
8 + isLoading,
9 + indexLoaded,
10 + error,
11 + selectedIndex,
12 + showResults,
13 + initIndex,
14 + performSearch,
15 + clearSearch,
16 + navigateResults
17 + } from '$lib/stores/searchStore';
18 +
19 + // Pre-warm Supabase connection (evita cold start al navegar)
20 + async function warmupSupabase() {
21 + console.time('[WARMUP] Supabase');
22 + await supabase.schema('ppto').from('vista_objeto_estado').select('objeto').limit(1);
23 + console.timeEnd('[WARMUP] Supabase');
24 + }
25 +
26 + let mounted = false;
27 + let mode = 'archivo';
28 + let searchFocused = false;
29 + let searchVal = '';
30 + let hDiario = false;
31 + let hArchivo = false;
32 + let hVis = null;
33 + let hHist = null;
34 + let hWidget = null;
35 + let hGear = false;
36 + let mobileSettingsOpen = false;
37 + let gearMenuOpen = false;
38 + let searchInput;
39 + let debounceTimer;
40 + let autoFocused = false;
41 +
42 + // Search filters - which types to include
43 + let searchFilters = {
44 + entidad: true,
45 + objeto_gasto: true,
46 + rubro: true
47 + };
48 +
49 + // Filtered results based on selected filters
50 + $: filteredResults = $results.filter(item => searchFilters[item.tipo]);
51 +
52 + function toggleFilter(tipo) {
53 + searchFilters[tipo] = !searchFilters[tipo];
54 + searchFilters = searchFilters; // trigger reactivity
55 + }
56 +
57 + function toggleGearMenu() {
58 + gearMenuOpen = !gearMenuOpen;
59 + }
60 +
61 + onMount(() => {
62 + setTimeout(() => { mounted = true; }, 80);
63 + initIndex();
64 + // Pre-calentar Supabase en background para evitar cold start
65 + warmupSupabase();
66 +
67 + // Cerrar gear menu al hacer click fuera
68 + const handleClickOutside = (e) => {
69 + if (gearMenuOpen && !e.target.closest('.gear-wrap')) {
70 + gearMenuOpen = false;
71 + }
72 + };
73 + document.addEventListener('click', handleClickOutside);
74 + return () => document.removeEventListener('click', handleClickOutside);
75 + });
76 +
77 + // Auto-focus en el buscador cuando el índice esté listo (solo desktop)
78 + $: if ($indexLoaded && searchInput && !autoFocused) {
79 + autoFocused = true;
80 + // No auto-focus en móvil para evitar que aparezca el teclado
81 + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
82 + if (!isMobile) {
83 + setTimeout(() => {
84 + searchInput?.focus();
85 + }, 50);
86 + }
87 + }
88 +
89 + function toggleMobileSettings() {
90 + mobileSettingsOpen = !mobileSettingsOpen;
91 + }
92 +
93 + $: isD = mode === 'diario';
94 + $: accent = isD ? '#4A8B6E' : '#C9A751';
95 + $: placeholder = isD
96 + ? 'Busca una entidad, un ministerio, un municipio...'
97 + : 'Busca instituciones, gastos, ingresos, sectores...';
98 + $: examples = isD
99 + ? [
100 + { label: 'MEFP', bg: 'rgba(140,170,150,0.10)', color: '#9ab8a8' },
101 + { label: 'Min. Educación', bg: 'rgba(150,155,175,0.10)', color: '#a0a5b8' },
102 + { label: 'GOB. La Paz', bg: 'rgba(180,130,100,0.10)', color: '#b8947a' },
103 + ]
104 + : [
105 + { label: 'Sueldos', bg: 'rgba(201,167,81,0.12)', color: '#d4b962', href: '/objeto/11700' },
106 + { label: 'Regalías', bg: 'rgba(160,150,170,0.09)', color: '#aaa0b0', href: '/rubro/14000' },
107 + { label: 'BOA', bg: 'rgba(150,155,175,0.10)', color: '#a0a5b8' },
108 + { label: 'MEFP', bg: 'rgba(140,170,150,0.10)', color: '#9ab8a8' },
109 + { label: 'Publicidad', bg: 'rgba(180,130,100,0.10)', color: '#b8947a' },
110 + ];
111 +
112 + function switchMode(m) {
113 + mode = m;
114 + searchVal = '';
115 + clearSearch();
116 + if (searchInput) searchInput.value = '';
117 + }
118 +
119 + function handleBlur() {
120 + setTimeout(() => { searchFocused = false; }, 200);
121 + }
122 +
123 + function handleInput(e) {
124 + const value = e.target.value;
125 + searchVal = value;
126 + clearTimeout(debounceTimer);
127 + debounceTimer = setTimeout(() => {
128 + performSearch(value);
129 + }, 250);
130 + }
131 +
132 + function handleKeydown(e) {
133 + if (!$showResults) return;
134 + switch (e.key) {
135 + case 'ArrowDown':
136 + e.preventDefault();
137 + navigateResults('down', filteredResults.length);
138 + break;
139 + case 'ArrowUp':
140 + e.preventDefault();
141 + navigateResults('up', filteredResults.length);
142 + break;
143 + case 'Enter':
144 + e.preventDefault();
145 + // Si hay un resultado seleccionado, navegar a él
146 + if ($selectedIndex >= 0 && filteredResults[$selectedIndex]) {
147 + handleSelect(filteredResults[$selectedIndex]);
148 + } else if (filteredResults.length > 0) {
149 + // Si no hay selección pero hay resultados, ir al primero
150 + handleSelect(filteredResults[0]);
151 + }
152 + break;
153 + case 'Escape':
154 + clearSearch();
155 + searchVal = '';
156 + if (searchInput) searchInput.value = '';
157 + searchInput?.blur();
158 + break;
159 + }
160 + }
161 +
162 + function handleSelect(item) {
163 + if (item.tipo === 'entidad') {
164 + goto(`/entidad/${item.codigo}`);
165 + } else if (item.tipo === 'objeto_gasto') {
166 + goto(`/objeto/${item.codigo}`);
167 + } else if (item.tipo === 'rubro') {
168 + goto(`/rubro/${item.codigo}`);
169 + }
170 + clearSearch();
171 + searchVal = '';
172 + if (searchInput) searchInput.value = '';
173 + }
174 +
175 + function getTypeLabel(tipo) {
176 + switch (tipo) {
177 + case 'entidad': return 'ENTIDAD';
178 + case 'objeto_gasto': return 'OBJETO';
179 + case 'rubro': return 'RUBRO';
180 + default: return tipo?.toUpperCase() || '';
181 + }
182 + }
183 +
184 + function getTypeColor(tipo) {
185 + switch (tipo) {
186 + case 'entidad': return '#4A8B6E';
187 + case 'objeto_gasto': return '#C9A751';
188 + case 'rubro': return '#8B6E9B';
189 + default: return '#6B6860';
190 + }
191 + }
192 +
193 + const WIDGETS_INGRESOS = [
194 + { id: 'rubros', label: 'tipos de ingreso', d: 'Impuestos, regalías, ventas, créditos', t: 'Recursos por rubros', n: '324', href: '/clasificadores/rubros' },
195 + { id: 'fuentes', label: 'fuentes de financiamiento', d: 'TGN, crédito externo, donaciones', t: 'Fuentes', n: '22', href: '/clasificadores/fuente' },
196 + { id: 'organismos', label: 'organismos financiadores', d: 'BID, Banco Mundial, cooperación', t: 'Organismos', n: '228', href: '/clasificadores/organismos' },
197 + ];
198 +
199 + const WIDGETS_GASTOS_QUE = [
200 + { id: 'objeto', label: 'objetos de gasto', d: 'Sueldos, consultorías, materiales', t: 'Objeto del gasto', n: '324', href: '/clasificadores/objeto-gasto' },
201 + { id: 'sector', label: 'sectores económicos', d: 'Industria, construcción, servicios', t: 'Sectores', n: '488', href: '/clasificadores/sectores' },
202 + ];
203 +
204 + const WIDGETS_GASTOS_BOTTOM = [
205 + { id: 'finalidad', label: 'finalidades y funciones', d: 'Educación, salud, defensa', t: 'Finalidad', n: '132', href: '/clasificadores/funcional' },
206 + { id: 'donde', label: 'ubicaciones geográficas', d: 'Departamentos, municipios', t: 'Geográfico', n: '352', href: '/clasificadores/geografico' },
207 + ];
208 +
209 + const HISTORIAS = [
210 + { 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 },
211 + { color: '#C44B3F', title: 'Salud vs. Defensa: 20 años', desc: 'Cómo se redistribuyó el presupuesto entre sectores.', tag: 'COMPARATIVA', tall: false },
212 + { 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 },
213 + { 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 },
214 + ];
215 +
216 + const VISUALIZACIONES = [
217 + { 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 },
218 + { color: '#C9A751', title: '¿En qué se gasta?', desc: 'Explora el presupuesto por objeto de gasto.', type: 'Treemap', href: '/clasificadores/objeto-gasto?modo=mapa' },
219 + { color: '#8B6E9B', title: 'Mapa de inversión por departamento', desc: 'Distribución geográfica de la inversión pública.', type: 'Mapa', href: null },
220 + ];
221 +
222 + /* Reveal action */
223 + let revealStates = {};
224 + function reveal(node, { id, delay = 0 }) {
225 + revealStates[id] = false;
226 + revealStates = revealStates;
227 + const obs = new IntersectionObserver(([e]) => {
228 + if (e.isIntersecting) {
229 + setTimeout(() => { revealStates[id] = true; revealStates = revealStates; }, delay);
230 + obs.disconnect();
231 + }
232 + }, { threshold: 0.08 });
233 + obs.observe(node);
234 + return { destroy() { obs.disconnect(); } };
235 + }
236 +
237 + /* Section navigation */
238 + const SECTIONS_NAV = [
239 + { id: 'hero', label: 'Inicio' },
240 + { id: 'datos', label: 'Datos' },
241 + { id: 'clasificadores', label: 'Clasificadores' },
242 + { id: 'historias', label: 'Historias' },
243 + { id: 'visualizaciones', label: 'Visualizaciones' },
244 + { id: 'manifiesto', label: 'Manifiesto' },
245 + { id: 'descargas', label: 'Descargas' },
246 + ];
247 + let currentSection = 'hero';
248 +
249 + onMount(() => {
250 + const sections = document.querySelectorAll('[data-section]');
251 + const observer = new IntersectionObserver((entries) => {
252 + entries.forEach(entry => {
253 + if (entry.isIntersecting) {
254 + currentSection = entry.target.dataset.section;
255 + }
256 + });
257 + }, { threshold: 0.5 });
258 + sections.forEach(section => observer.observe(section));
259 + return () => observer.disconnect();
260 + });
261 +
262 + function scrollToSection(id) {
263 + const el = document.querySelector(`[data-section="${id}"]`);
264 + if (el) el.scrollIntoView({ behavior: 'smooth' });
265 + }
266 +</script>
267 +
268 +<div class="page" class:mounted>
269 +
270 + <!-- Section Navigation Dots -->
271 + <nav class="section-dots">
272 + {#each SECTIONS_NAV as s}
273 + <button
274 + class="section-dot"
275 + class:section-dot-active={currentSection === s.id}
276 + on:click={() => scrollToSection(s.id)}
277 + aria-label={s.label}
278 + >
279 + <span class="section-dot-label">{s.label}</span>
280 + </button>
281 + {/each}
282 + </nav>
283 +
284 + <!-- ═══ LANDING SCREEN (NAV + HERO) ═══ -->
285 + <section class="landing-screen" data-section="hero">
286 + <nav>
287 + <div class="nav-logo"><img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" /></div>
288 + </nav>
289 +
290 + <!-- ═══ HERO ═══ -->
291 + <section class="hero">
292 + <div class="dot-pattern"></div>
293 + <div class="hero-inner">
294 + <!-- Bloque 1: Identidad -->
295 + <div class="hero-identity">
296 + <h1 class="hero-title anim" style="--d:200ms">Presupuesto <span class="gold">Abierto</span></h1>
297 + <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>
298 + <div class="hero-stats anim" style="--d:420ms">
299 + <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>
300 + </div>
301 + </div>
302 +
303 + <!-- Bloque 2: Búsqueda -->
304 + <div class="hero-search anim" style="--d:520ms">
305 + <div class="search-wrap">
306 + <!-- Search bar unificado -->
307 + <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}">
308 + <!-- Link al diario + Badge informativo (desktop) -->
309 + <div class="search-meta toggle-desktop">
310 + <a href="/vista-diaria" class="search-meta-item search-diario-link">
311 + Ver gasto de hoy
312 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
313 + <line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
314 + </svg>
315 + </a>
316 + <span class="search-meta-item search-badge">
317 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
318 + <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"/>
319 + </svg>
320 + Viendo 2005 – 2025
321 + </span>
322 + </div>
323 + <div class="search-divider"></div>
324 +
325 + <!-- Mobile: mode indicator + settings button -->
326 + <button class="mobile-settings-btn" class:mobile-settings-open={mobileSettingsOpen} on:click={toggleMobileSettings}>
327 + <span class="mobile-mode-indicator" style="background:{accent}"></span>
328 + <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">
329 + <circle cx="12" cy="12" r="3"/>
330 + <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"/>
331 + </svg>
332 + </button>
333 +
334 + <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">
335 + <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
336 + </svg>
337 + <div class="search-input-wrap">
338 + <input
339 + type="text"
340 + bind:this={searchInput}
341 + bind:value={searchVal}
342 + on:input={handleInput}
343 + on:keydown={handleKeydown}
344 + on:focus={() => searchFocused = true}
345 + on:blur={handleBlur}
346 + disabled={!$indexLoaded}
347 + />
348 + {#if !searchVal}
349 + <div class="search-placeholder">
350 + <span class="blink-cursor" class:cursor-diario={isD} class:cursor-archivo={!isD}></span>
351 + <span class="placeholder-text placeholder-desktop">{placeholder}</span>
352 + <span class="placeholder-text placeholder-mobile">{isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...'}</span>
353 + </div>
354 + {/if}
355 + {#if searchVal && !$isLoading}
356 + <button class="search-clear" on:click={() => { clearSearch(); searchVal = ''; if (searchInput) searchInput.value = ''; }}>
357 + <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>
358 + </button>
359 + {/if}
360 + </div>
361 + <!-- Gear button with filter dropdown (desktop) -->
362 + <div class="gear-wrap gear-desktop" on:click|stopPropagation>
363 + <button class="gear-btn" class:gear-active={gearMenuOpen} on:click={toggleGearMenu}>
364 + <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">
365 + <circle cx="12" cy="12" r="3"/>
366 + <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"/>
367 + </svg>
368 + </button>
369 + {#if gearMenuOpen}
370 + <div class="gear-menu">
371 + <div class="gear-menu-title">Ajusta tu búsqueda</div>
372 + <button class="gear-row" on:click={() => toggleFilter('entidad')}>
373 + <div class="gear-check" class:gear-check-on={searchFilters.entidad}>
374 + {#if searchFilters.entidad}
375 + <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>
376 + {/if}
377 + </div>
378 + <div class="gear-row-content">
379 + <span class="gear-row-question">¿Quién?</span>
380 + <span class="gear-row-label" class:gear-row-label-on={searchFilters.entidad}>Instituciones</span>
381 + </div>
382 + <span class="gear-row-count">756</span>
383 + </button>
384 + <button class="gear-row" on:click={() => toggleFilter('objeto_gasto')}>
385 + <div class="gear-check" class:gear-check-on={searchFilters.objeto_gasto}>
386 + {#if searchFilters.objeto_gasto}
387 + <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>
388 + {/if}
389 + </div>
390 + <div class="gear-row-content">
391 + <span class="gear-row-question">¿En qué?</span>
392 + <span class="gear-row-label" class:gear-row-label-on={searchFilters.objeto_gasto}>Objetos de gasto</span>
393 + </div>
394 + <span class="gear-row-count">324</span>
395 + </button>
396 + <button class="gear-row" on:click={() => toggleFilter('rubro')}>
397 + <div class="gear-check" class:gear-check-on={searchFilters.rubro}>
398 + {#if searchFilters.rubro}
399 + <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>
400 + {/if}
401 + </div>
402 + <div class="gear-row-content">
403 + <span class="gear-row-question">¿De dónde?</span>
404 + <span class="gear-row-label" class:gear-row-label-on={searchFilters.rubro}>Rubros de ingreso</span>
405 + </div>
406 + <span class="gear-row-count">324</span>
407 + </button>
408 + </div>
409 + {/if}
410 + </div>
411 + </div>
412 +
413 + <!-- Search Results Dropdown -->
414 + {#if $isLoading && $query.length >= 2}
415 + <div class="search-results">
416 + <div class="search-loading">Buscando...</div>
417 + </div>
418 + {:else if searchFocused && filteredResults.length > 0}
419 + <div class="search-results">
420 + {#each filteredResults as item, i}
421 + <button
422 + type="button"
423 + class="search-result-item"
424 + class:result-selected={$selectedIndex === i}
425 + on:click={() => handleSelect(item)}
426 + >
427 + <div class="result-main">
428 + <span class="result-name">{item.nombre}</span>
429 + {#if item.sigla}
430 + <span class="result-sigla">({item.sigla})</span>
431 + {/if}
432 + </div>
433 + <div class="result-meta">
434 + {#if item.codigo}
435 + <span class="result-code">{item.codigo}</span>
436 + {/if}
437 + <span class="result-type" style="color:{getTypeColor(item.tipo)}">{getTypeLabel(item.tipo)}</span>
438 + </div>
439 + </button>
440 + {/each}
441 + </div>
442 + {:else if searchFocused && $query.length >= 2 && filteredResults.length === 0}
443 + <div class="search-results">
444 + <div class="search-no-results">Sin resultados para "{$query}"</div>
445 + </div>
446 + {/if}
447 +
448 + {#if $error}
449 + <div class="search-error">{$error}</div>
450 + {/if}
451 +
452 + <!-- Mobile settings panel -->
453 + <div class="mobile-settings-panel" class:mobile-settings-panel-open={mobileSettingsOpen}>
454 + <div class="mobile-meta-bar">
455 + <a href="/vista-diaria" class="mobile-meta-item mobile-diario-link">
456 + Ver hoy
457 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
458 + <line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
459 + </svg>
460 + </a>
461 + <span class="mobile-meta-item mobile-search-badge">
462 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
463 + <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"/>
464 + </svg>
465 + Viendo 2005 – 2025
466 + </span>
467 + </div>
468 + <div class="mobile-filter-section">
469 + <div class="mobile-filter-title">Buscar</div>
470 + <div class="mobile-filter-bar">
471 + <button class="mobile-filter-chip" class:filter-chip-active={searchFilters.entidad} on:click={() => toggleFilter('entidad')}>
472 + <span class="filter-chip-dot" style="background:#4A8B6E"></span>
473 + Instituciones
474 + </button>
475 + <button class="mobile-filter-chip" class:filter-chip-active={searchFilters.objeto_gasto} on:click={() => toggleFilter('objeto_gasto')}>
476 + <span class="filter-chip-dot" style="background:#C9A751"></span>
477 + Gastos
478 + </button>
479 + <button class="mobile-filter-chip" class:filter-chip-active={searchFilters.rubro} on:click={() => toggleFilter('rubro')}>
480 + <span class="filter-chip-dot" style="background:#8B6E9B"></span>
481 + Ingresos
482 + </button>
483 + </div>
484 + </div>
485 + </div>
486 +
487 + <!-- Pills -->
488 + <div class="pills">
489 + <span class="pills-label">Ej:</span>
490 + {#each examples as ex}
491 + {#if ex.href}
492 + <a class="pill" style="color:{ex.color};background:{ex.bg}" href={ex.href}>{ex.label}</a>
493 + {:else}
494 + <button class="pill" style="color:{ex.color};background:{ex.bg}" on:click={() => searchVal = ex.label}>{ex.label}</button>
495 + {/if}
496 + {/each}
497 + </div>
498 + </div>
499 + </div>
500 + </div>
501 +
502 + <!-- Scroll hint -->
503 + <div class="scroll-hint anim" style="--d:700ms" class:scroll-hint-hidden={searchFocused && $results.length > 0}>
504 + <svg width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="#B8B5AD" stroke-width="1.2" stroke-linecap="round">
505 + <rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
506 + </svg>
507 + <span>Explora el portal</span>
508 + </div>
509 + </section>
510 + </section>
511 +
512 + <!-- ═══ PORTAL DIRECTORY ═══ -->
513 + <section class="directory" data-section="datos">
514 + <div class="directory-inner">
515 + <!-- Left: Title -->
516 + <div class="directory-left">
517 + <div use:reveal={{ id: 'dir-head' }} class="reveal" class:revealed={revealStates['dir-head']}>
518 + <p class="section-tag">LOS DATOS</p>
519 + <h2 class="section-title">Dos formas<br/>de ver el<br/>presupuesto.</h2>
520 + </div>
521 + </div>
522 +
523 + <!-- Right: Cards -->
524 + <div class="directory-right">
525 + <!-- Diario -->
526 + <div use:reveal={{ id: 'door-d', delay: 60 }} class="reveal" class:revealed={revealStates['door-d']}>
527 + <div class="door door-warm" class:door-hovered={hDiario} on:mouseenter={() => hDiario = true} on:mouseleave={() => hDiario = false} role="button" tabindex="0">
528 + <div class="door-content">
529 + <div class="door-header">
530 + <div class="door-icon" style="opacity:{hDiario ? 1 : 0.4}">
531 + <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>
532 + </div>
533 + <div class="door-badge">
534 + <div class="pulse-dot-sm"></div>
535 + <span class="door-badge-text" style="color:#4A8B6E">EN VIVO</span>
536 + </div>
537 + </div>
538 + <h3 class="door-title door-title-dark">Diariamente</h3>
539 + <div class="door-pills">
540 + <span class="door-pill door-pill-green">¿Cuánto se ejecutó hoy?</span>
541 + <span class="door-pill door-pill-green">¿Quién gastó más?</span>
542 + <span class="door-pill door-pill-green">¿Qué cambió desde ayer?</span>
543 + <span class="door-pill door-pill-green">Panorama del presupuesto en curso</span>
544 + <span class="door-pill door-pill-green">Bitácora de movimientos</span>
545 + <span class="door-pill door-pill-green">Descarga de datos diaria</span>
546 + <span class="door-pill door-pill-green">Vista de todo el Estado</span>
547 + <span class="door-pill door-pill-green">Vista por Institución</span>
548 + </div>
549 + </div>
550 + <div class="door-footer">
551 + <span class="door-meta">ACTUALIZADO HACE 2 HORAS</span>
552 + <span class="door-cta" style="color:{hDiario ? '#4A8B6E' : '#6B6860'}">Explorar →</span>
553 + </div>
554 + </div>
555 + </div>
556 +
557 + <!-- Archivo -->
558 + <div use:reveal={{ id: 'door-a', delay: 160 }} class="reveal" class:revealed={revealStates['door-a']}>
559 + <div class="door door-warm" class:door-hovered={hArchivo} on:mouseenter={() => hArchivo = true} on:mouseleave={() => hArchivo = false} role="button" tabindex="0">
560 + <div class="door-content">
561 + <div class="door-header">
562 + <div class="door-icon" style="opacity:{hArchivo ? 1 : 0.4}">
563 + <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>
564 + </div>
565 + <span class="door-badge-text" style="color:#A68A3E">2005 — 2025</span>
566 + </div>
567 + <h3 class="door-title door-title-dark">Históricamente</h3>
568 + <div class="door-pills">
569 + <span class="door-pill door-pill-light">Tendencias</span>
570 + <span class="door-pill door-pill-light">Tipos de ingreso</span>
571 + <span class="door-pill door-pill-light">Tipos de gasto</span>
572 + <span class="door-pill door-pill-light">Por institución</span>
573 + <span class="door-pill door-pill-light">Por sector</span>
574 + <span class="door-pill door-pill-light">Por autonomías</span>
575 + <span class="door-pill door-pill-light">Comparar entidades</span>
576 + </div>
577 + </div>
578 + <div class="door-footer">
579 + <span class="door-meta">8M REGISTROS · 7 CLASIFICADORES</span>
580 + <span class="door-cta" style="color:{hArchivo ? '#C9A751' : '#6B6860'}">Explorar →</span>
581 + </div>
582 + </div>
583 + </div>
584 + </div>
585 + </div>
586 + </section>
587 +
588 + <!-- ═══ CLASIFICADORES ═══ -->
589 + <section id="clasificadores" class="clasificadores" data-section="clasificadores">
590 + <div class="clas-container">
591 + <!-- Header -->
592 + <div use:reveal={{ id: 'clas-head' }} class="reveal clas-header-section" class:revealed={revealStates['clas-head']}>
593 + <div class="clas-header-row">
594 + <div>
595 + <p class="section-tag">CLASIFICADORES</p>
596 + <h2 class="section-title">Elige cómo explorar el presupuesto</h2>
597 + <p class="clas-subtitle">Navega por cada una de las 2,626 piezas de los 8 clasificadores.</p>
598 + </div>
599 + <a href="/clasificadores" class="clas-ver-todos">Ver todos →</a>
600 + </div>
601 + </div>
602 +
603 + <!-- ¿Quién? -->
604 + <div use:reveal={{ id: 'clas-quien', delay: 100 }} class="reveal" class:revealed={revealStates['clas-quien']}>
605 + <div class="clas-group-card">
606 + <div class="clas-group-header">
607 + <div class="clas-group-icon" style="--c:#1C1C1A">
608 + <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>
609 + </div>
610 + <div>
611 + <div class="clas-group-label">¿QUIÉN?</div>
612 + <div class="clas-group-sub">La entidad que gasta o recibe</div>
613 + </div>
614 + </div>
615 + <a href="/clasificadores/institucional" class="widget-card widget-h" class:widget-hover={hWidget === 'quien'} on:mouseenter={() => hWidget = 'quien'} on:mouseleave={() => hWidget = null}>
616 + <div class="widget-left">
617 + <div class="widget-headline">
618 + <span class="widget-num-big">756</span>
619 + <span class="widget-label">instituciones públicas</span>
620 + </div>
621 + <div class="widget-desc">Ministerios, gobernaciones, municipios, empresas públicas, universidades</div>
622 + </div>
623 + <div class="widget-right">
624 + <span class="widget-tech">INSTITUCIONAL</span>
625 + <span class="widget-arrow" class:arrow-show={hWidget === 'quien'}>→</span>
626 + </div>
627 + </a>
628 + </div>
629 + </div>
630 +
631 + <!-- Grid: Ingresos + Gastos -->
632 + <div class="clas-main-grid">
633 + <!-- INGRESOS -->
634 + <div use:reveal={{ id: 'clas-ing', delay: 200 }} class="reveal clas-col" class:revealed={revealStates['clas-ing']}>
635 + <div class="clas-group-card">
636 + <div class="clas-group-header">
637 + <div class="clas-group-icon" style="--c:#C9A751">
638 + <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>
639 + </div>
640 + <div>
641 + <div class="clas-group-label" style="color:#C9A751">INGRESOS</div>
642 + </div>
643 + </div>
644 + <div class="widgets-col">
645 + {#each WIDGETS_INGRESOS as w}
646 + <a href={w.href} class="widget-card widget-grow" class:widget-hover={hWidget === w.id} on:mouseenter={() => hWidget = w.id} on:mouseleave={() => hWidget = null}>
647 + <div class="widget-headline">
648 + <span class="widget-num-big widget-num-gold">{w.n}</span>
649 + <span class="widget-label">{w.label}</span>
650 + </div>
651 + <p class="widget-desc">{w.d}</p>
652 + <div class="widget-footer">
653 + <span class="widget-tech">{w.t}</span>
654 + <span class="widget-arrow" class:arrow-show={hWidget === w.id}>→</span>
655 + </div>
656 + </a>
657 + {/each}
658 + </div>
659 + </div>
660 + </div>
661 +
662 + <!-- GASTOS -->
663 + <div use:reveal={{ id: 'clas-gas', delay: 300 }} class="reveal clas-col" class:revealed={revealStates['clas-gas']}>
664 + <div class="clas-group-card">
665 + <div class="clas-group-header">
666 + <div class="clas-group-icon" style="--c:#4A8B6E">
667 + <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>
668 + </div>
669 + <div>
670 + <div class="clas-group-label" style="color:#4A8B6E">GASTOS</div>
671 + </div>
672 + </div>
673 +
674 + <!-- Gastos content wrapper -->
675 + <div class="gastos-content">
676 + <!-- ¿En qué? sub-group -->
677 + <div class="clas-subgroup">
678 + <div class="clas-subgroup-header">
679 + <div class="clas-subgroup-icon" style="--c:#4A8B6E">
680 + <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>
681 + </div>
682 + <div>
683 + <div class="clas-subgroup-label">¿EN QUÉ?</div>
684 + </div>
685 + </div>
686 + <div class="widgets-row">
687 + {#each WIDGETS_GASTOS_QUE as w}
688 + <a href={w.href} class="widget-card widget-sm" class:widget-hover={hWidget === w.id} on:mouseenter={() => hWidget = w.id} on:mouseleave={() => hWidget = null}>
689 + <div class="widget-headline">
690 + <span class="widget-num-big widget-num-green">{w.n}</span>
691 + <span class="widget-label">{w.label}</span>
692 + </div>
693 + <p class="widget-desc widget-desc-sm">{w.d}</p>
694 + <div class="widget-footer">
695 + <span class="widget-tech">{w.t}</span>
696 + <span class="widget-arrow" class:arrow-show={hWidget === w.id}>→</span>
697 + </div>
698 + </a>
699 + {/each}
700 + </div>
701 + </div>
702 +
703 + <!-- Bottom: Para qué + Dónde -->
704 + <div class="widgets-row">
705 + <!-- ¿Para qué? -->
706 + <div class="clas-subgroup">
707 + <div class="clas-subgroup-header">
708 + <div class="clas-subgroup-icon" style="--c:#4A8B6E">
709 + <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>
710 + </div>
711 + <div>
712 + <div class="clas-subgroup-label">¿PARA QUÉ?</div>
713 + </div>
714 + </div>
715 + <a href="/clasificadores/funcional" class="widget-card widget-full" class:widget-hover={hWidget === 'finalidad'} on:mouseenter={() => hWidget = 'finalidad'} on:mouseleave={() => hWidget = null}>
716 + <div class="widget-headline">
717 + <span class="widget-num-big widget-num-green">132</span>
718 + <span class="widget-label">finalidades y funciones</span>
719 + </div>
720 + <p class="widget-desc">Educación, salud, defensa</p>
721 + <div class="widget-footer">
722 + <span class="widget-tech">Finalidad</span>
723 + <span class="widget-arrow" class:arrow-show={hWidget === 'finalidad'}>→</span>
724 + </div>
725 + </a>
726 + </div>
727 +
728 + <!-- ¿Dónde? -->
729 + <div class="clas-subgroup">
730 + <div class="clas-subgroup-header">
731 + <div class="clas-subgroup-icon" style="--c:#4A8B6E">
732 + <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>
733 + </div>
734 + <div>
735 + <div class="clas-subgroup-label">¿DÓNDE?</div>
736 + </div>
737 + </div>
738 + <a href="/clasificadores/geografico" class="widget-card widget-full" class:widget-hover={hWidget === 'donde'} on:mouseenter={() => hWidget = 'donde'} on:mouseleave={() => hWidget = null}>
739 + <div class="widget-headline">
740 + <span class="widget-num-big widget-num-green">352</span>
741 + <span class="widget-label">ubicaciones geográficas</span>
742 + </div>
743 + <p class="widget-desc">Departamentos, municipios</p>
744 + <div class="widget-footer">
745 + <span class="widget-tech">Geográfico</span>
746 + <span class="widget-arrow" class:arrow-show={hWidget === 'donde'}>→</span>
747 + </div>
748 + </a>
749 + </div>
750 + </div>
751 + </div>
752 + </div>
753 + </div>
754 + </div>
755 + </div>
756 + </section>
757 +
758 + <!-- ═══ HISTORIAS ═══ -->
759 + <section id="historias" class="historias" data-section="historias">
760 + <div class="container">
761 + <div use:reveal={{ id: 'hist-head' }} class="reveal" class:revealed={revealStates['hist-head']}>
762 + <div class="hist-header">
763 + <div>
764 + <div class="hist-tag-row">
765 + <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>
766 + <p class="section-tag">HISTORIAS</p>
767 + </div>
768 + <h2 class="section-title">Los datos, contados.</h2>
769 + <p class="hist-subtitle">Investigaciones y análisis narrativos sobre el presupuesto público.</p>
770 + </div>
771 + <a href="/historias" class="gold-link">Ver todas →</a>
772 + </div>
773 + </div>
774 + <div class="hist-grid">
775 + {#each HISTORIAS as h, i}
776 + <div use:reveal={{ id: `hist-${i}`, delay: 60 + i * 80 }} class="reveal hist-cell" class:revealed={revealStates[`hist-${i}`]} class:hist-tall={h.tall}>
777 + <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}">
778 + <div class="hist-accent" style="background:{h.color};transform:scaleX({hHist === i ? 1 : 0})"></div>
779 + <div class="hist-content">
780 + <span class="hist-tag" style="color:{h.color}">{h.tag}</span>
781 + <h4 class="hist-title">{h.title}</h4>
782 + <p class="hist-desc">{h.desc}</p>
783 + </div>
784 + <div class="hist-cta" style="color:{hHist === i ? h.color : '#6B6860'}">
785 + Leer historia <span class="hist-arrow" class:hist-arrow-moved={hHist === i}>→</span>
786 + </div>
787 + </div>
788 + </div>
789 + {/each}
790 + </div>
791 + </div>
792 + </section>
793 +
794 + <!-- ═══ VISUALIZACIONES ═══ -->
795 + <section id="visualizaciones" class="visualizaciones" data-section="visualizaciones">
796 + <div class="container">
797 + <div use:reveal={{ id: 'vis-head' }} class="reveal" class:revealed={revealStates['vis-head']}>
798 + <div class="vis-header">
799 + <div>
800 + <div class="vis-tag-row">
801 + <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>
802 + <p class="section-tag">VISUALIZACIONES</p>
803 + </div>
804 + <h2 class="section-title">Explora los datos.</h2>
805 + <p class="vis-subtitle">Herramientas interactivas para navegar el presupuesto.</p>
806 + </div>
807 + <a href="/visualizaciones" class="gold-link">Ver todas →</a>
808 + </div>
809 + </div>
810 + <div class="vis-grid-simple">
811 + {#each VISUALIZACIONES as v, i}
812 + <div use:reveal={{ id: `vis-${i}`, delay: 60 + i * 80 }} class="reveal" class:revealed={revealStates[`vis-${i}`]}>
813 + {#if v.href}
814 + <a href={v.href} class="vis-card vis-card-link" class:vis-card-hovered={hVis === i} on:mouseenter={() => hVis = i} on:mouseleave={() => hVis = null}>
815 + <div class="vis-accent" style="background:{v.color};transform:scaleX({hVis === i ? 1 : 0})"></div>
816 + <div>
817 + <span class="vis-type" style="background:color-mix(in srgb, {v.color} 12%, transparent);color:{v.color}">{v.type}</span>
818 + <h4 class="vis-title">{v.title}</h4>
819 + <p class="vis-desc">{v.desc}</p>
820 + </div>
821 + <div class="vis-cta" style="color:{hVis === i ? v.color : '#6B6860'}">
822 + Explorar <span class="vis-arrow" class:vis-arrow-moved={hVis === i}>→</span>
823 + </div>
824 + </a>
825 + {:else}
826 + <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">
827 + <div class="vis-accent" style="background:{v.color};transform:scaleX({hVis === i ? 1 : 0})"></div>
828 + <div>
829 + <span class="vis-type" style="background:color-mix(in srgb, {v.color} 12%, transparent);color:{v.color}">{v.type}</span>
830 + <h4 class="vis-title">{v.title}</h4>
831 + <p class="vis-desc">{v.desc}</p>
832 + </div>
833 + <div class="vis-cta" style="color:#6B6860">
834 + Próximamente
835 + </div>
836 + </div>
837 + {/if}
838 + </div>
839 + {/each}
840 + </div>
841 + </div>
842 + </section>
843 +
844 + <!-- ═══ MANIFIESTO ═══ -->
845 + <section class="manifiesto" data-section="manifiesto">
846 + <div class="dot-pattern"></div>
847 + <div class="manifiesto-inner">
848 + <div use:reveal={{ id: 'man' }} class="reveal" class:revealed={revealStates['man']}>
849 + <p class="section-tag-light">POR QUÉ EXISTE ESTO</p>
850 + <h2 class="manifiesto-title">El presupuesto público no es de los gobiernos.<br/><span class="gold">Es de todos.</span></h2>
851 + </div>
852 + </div>
853 + </section>
854 +
855 + <!-- ═══ DESCARGAS ═══ -->
856 + <section id="descargas" class="descargas" data-section="descargas">
857 + <div class="descargas-inner">
858 + <div use:reveal={{ id: 'desc' }} class="reveal" class:revealed={revealStates['desc']}>
859 + <h2 class="descargas-title">Descarga los datos.</h2>
860 + <div class="descargas-formats">
861 + <span class="format-pill">.csv</span>
862 + <span class="format-pill">.parquet</span>
863 + <span class="format-pill">.gpkg</span>
864 + </div>
865 + <a href="/descargas" class="descargas-link">
866 + <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>
867 + Ir a página de descargas
868 + </a>
869 + </div>
870 + </div>
871 + </section>
872 +
873 + <!-- ═══ FOOTER ═══ -->
874 + <footer>
875 + <div class="footer-inner">
876 + <div class="footer-org">MINISTERIO DE ECONOMÍA Y FINANZAS PÚBLICAS</div>
877 + <div class="footer-right">DATOS ABIERTOS · 2005 — 2026</div>
878 + </div>
879 + </footer>
880 +</div>
881 +
882 +<style>
883 + @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap');
884 + :global(*){margin:0;padding:0;box-sizing:border-box}
885 + :global(html){width:100%;height:100%;margin:0;padding:0;overflow-x:hidden;background:#1C1C1A;scroll-behavior:smooth}
886 + :global(body){width:100%;min-height:100%;margin:0;padding:0;overflow-x:hidden;background:#1C1C1A}
887 + :global(::selection){background:#C9A751;color:#1C1C1A}
888 + :global(::-webkit-scrollbar){width:5px}
889 + :global(::-webkit-scrollbar-track){background:#1C1C1A}
890 + :global(::-webkit-scrollbar-thumb){background:#444;border-radius:3px}
891 + :global(a){color:inherit;text-decoration:none}
892 +
893 + @keyframes pulse{0%,100%{opacity:0.35}50%{opacity:1}}
894 + @keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
895 + @keyframes scrollHint{0%,100%{transform:translateY(0);opacity:0.6}50%{transform:translateY(5px);opacity:1}}
896 + @keyframes heartbeat{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:48}}
897 +
898 + .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}
899 +
900 + /* Section Navigation Dots */
901 + .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}
902 + .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}
903 + .section-dot:hover{border-color:rgba(100,100,100,0.6);background:rgba(150,150,150,0.4);transform:scale(1.2)}
904 + .section-dot-active{background:#C9A751;border-color:#C9A751}
905 + .section-dot-active:hover{transform:scale(1.2)}
906 + .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}
907 + .section-dot:hover .section-dot-label{opacity:1}
908 + .gold{color:#C9A751}
909 + .container{max-width:1060px;margin:0 auto}
910 +
911 + /* Reveal */
912 + .reveal{opacity:0;transform:translateY(28px);transition:all 0.8s cubic-bezier(0.16,1,0.3,1)}
913 + .revealed{opacity:1!important;transform:translateY(0)!important}
914 + .anim{opacity:0;transform:translateY(18px);transition:all 0.8s cubic-bezier(0.16,1,0.3,1);transition-delay:var(--d,0ms)}
915 + .mounted .anim{opacity:1;transform:translateY(0)}
916 +
917 + /* ── NAV ── */
918 + nav{background:#1C1C1A;padding:12px 48px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
919 + .nav-left{display:flex;align-items:center}
920 + .nav-right{display:flex;align-items:center;gap:24px}
921 + .nav-link{font-family:var(--sans);font-size:14px;color:#B8B5AD;letter-spacing:0.01em;font-weight:400;transition:color 0.2s}
922 + .nav-link:hover{color:#F5F0E8}
923 + .nav-link-icon{display:flex;align-items:center;gap:5px}
924 + .heartbeat-line{animation:heartbeat 3s ease-in-out infinite}
925 + .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}
926 + .date-label{color:#9B9890}
927 + .date-sep{margin:0 3px;opacity:0.3}
928 + .pulse-dot{width:5px;height:5px;border-radius:50%;background:#4A8B6E;animation:pulse 2s ease-in-out infinite}
929 + .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)}
930 + .nav-sep{width:1px;height:16px;background:#2A2A27}
931 + .nav-logo{height:88px;display:flex;align-items:center;flex-shrink:0}
932 + .nav-logo img{height:100%;width:auto;object-fit:contain}
933 +
934 + /* ── HERO ── */
935 + .landing-screen{height:100vh;background:#1C1C1A;display:flex;flex-direction:column;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden}
936 + .hero{background:#1C1C1A;color:#F5F0E8;flex:1;display:flex;flex-direction:column;justify-content:center;padding:40px 48px 32px;position:relative;overflow:hidden}
937 + .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}
938 + .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}
939 + .hero-inner{max-width:1060px;margin:0 auto;width:100%;position:relative;margin-top:-80px;text-align:center}
940 + .hero-identity{margin-bottom:20px}
941 + .hero-search{display:flex;justify-content:center;position:relative;z-index:50}
942 + .hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD}
943 + .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}
944 + .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}
945 + .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}
946 + .hero-sub-link:hover{color:#4A8B6E;border-bottom-color:#4A8B6E}
947 + .hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1}
948 + .stat-num{color:#F5F0E8}
949 + .stat-dot{margin:0 10px;opacity:0.3}
950 + .stat-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(184,181,173,0.4);transition:all 0.2s;padding-bottom:1px}
951 + .stat-link:hover{color:#C9A751;border-bottom-color:#C9A751}
952 +
953 + /* Search */
954 + .search-wrap{max-width:960px;width:100%;margin:0 auto;position:relative;z-index:9999}
955 + .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}
956 + .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)}
957 + .search-has-results{border-radius:16px 16px 0 0}
958 + .search-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;padding:0 4px}
959 + .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}
960 + .search-meta-item svg{display:block;flex-shrink:0}
961 + .search-badge{color:#4A8B6E;background:rgba(74,139,110,0.12)}
962 + .search-diario-link{color:#C9A751;background:rgba(201,167,81,0.12)}
963 + .search-diario-link:hover{background:rgba(201,167,81,0.22);color:#D4B65E}
964 + .search-diario-link svg{transition:transform 0.2s}
965 + .search-diario-link:hover svg{transform:translateX(3px)}
966 + .search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0}
967 + .search-icon{flex-shrink:0;transition:stroke 0.3s}
968 + .search-input-wrap{flex:1;position:relative;display:flex;align-items:center}
969 + .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}
970 + .search-placeholder{position:absolute;left:10px;top:50%;transform:translateY(-50%);display:flex;align-items:center;pointer-events:none}
971 + .blink-cursor{width:1.5px;height:18px;margin-right:1px;animation:blink 1s step-end infinite;border-radius:1px;flex-shrink:0}
972 + .cursor-diario{background:#4A8B6E}
973 + .cursor-archivo{background:#C9A751}
974 + .placeholder-text{font-family:var(--sans);font-size:16px;color:#B8B5AD}
975 + .placeholder-mobile{display:none}
976 + .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}
977 + .gear-btn svg{transition:transform 0.3s ease}
978 + .gear-btn:hover svg{transform:rotate(45deg)}
979 + .gear-btn:hover,.gear-active{background:rgba(255,255,255,0.12)}
980 + .gear-wrap{position:relative}
981 + .gear-desktop{display:block}
982 + .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}
983 + @keyframes gearFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
984 + .gear-menu-title{font-family:var(--sans);font-size:11px;font-weight:400;color:#6B6860;padding:10px 14px 6px;letter-spacing:0.02em}
985 + .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}
986 + .gear-row:hover{background:rgba(255,255,255,0.04)}
987 + .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}
988 + .gear-check-on{border-color:transparent;background:#C9A751;color:#1C1C1A}
989 + .gear-row-content{flex:1;display:flex;align-items:baseline;gap:8px}
990 + .gear-row-question{font-family:var(--sans);font-size:12px;color:#5A5850;font-weight:400}
991 + .gear-row-label{font-family:var(--sans);font-size:14px;color:#8B8880;transition:color 0.15s}
992 + .gear-row-label-on{color:#F5F0E8}
993 + .gear-row-count{font-family:var(--mono);font-size:10px;color:#4A4840}
994 +
995 + /* Mobile settings button (hidden on desktop) */
996 + .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}
997 + .mobile-settings-btn:hover,.mobile-settings-open{background:rgba(255,255,255,0.12)}
998 + .mobile-mode-indicator{position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%}
999 +
1000 + /* Mobile settings panel (hidden on desktop) */
1001 + .mobile-settings-panel{display:none;overflow:hidden;max-height:0;opacity:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
1002 + .mobile-settings-panel-open{max-height:300px;opacity:1;margin-top:12px}
1003 + .mobile-meta-bar{display:flex;gap:10px;padding:4px 0}
1004 + .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}
1005 + .mobile-meta-item svg{flex-shrink:0}
1006 + .mobile-search-badge{color:#4A8B6E;background:rgba(74,139,110,0.12)}
1007 + .mobile-diario-link{color:#C9A751;background:rgba(201,167,81,0.12)}
1008 + .mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(201,167,81,0.22)}
1009 + .mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)}
1010 + .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}
1011 + .mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap}
1012 + .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}
1013 + .mobile-filter-chip.filter-chip-active{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.15);color:#F5F0E8}
1014 + .filter-chip-dot{width:6px;height:6px;border-radius:50%;opacity:0.4;transition:opacity 0.2s}
1015 + .mobile-filter-chip.filter-chip-active .filter-chip-dot{opacity:1}
1016 +
1017 + /* Search clear button */
1018 + .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}
1019 + .search-clear:hover{color:#F5F0E8}
1020 +
1021 + /* Search Results Dropdown */
1022 + .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}
1023 + .search-loading,.search-no-results{padding:16px 20px;font-family:var(--sans);font-size:14px;color:#9B9890;text-align:center}
1024 + .search-error{margin-top:8px;font-family:var(--sans);font-size:13px;color:#C44B3F;text-align:center}
1025 +
1026 + .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}
1027 + .search-result-item:first-child{border-top:none}
1028 + .search-result-item:hover,.result-selected{background:rgba(255,255,255,0.06)}
1029 +
1030 + .result-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0}
1031 + .result-name{font-family:var(--sans);font-size:14px;color:#F5F0E8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1032 + .result-sigla{font-family:var(--sans);font-size:12px;color:#9B9890;flex-shrink:0}
1033 +
1034 + .result-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:12px}
1035 + .result-code{font-family:var(--mono);font-size:10px;color:#9B9890;background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px}
1036 + .result-type{font-family:var(--mono);font-size:9px;letter-spacing:0.1em;font-weight:600}
1037 + .pills{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;align-items:center;justify-content:center}
1038 + .pills-label{font-family:var(--mono);font-size:11px;letter-spacing:0.1em;color:#B8B5AD;margin-right:2px}
1039 + .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}
1040 + .pill::before{content:'';position:absolute;inset:0;background:rgba(255,255,255,0.12);opacity:0;transition:opacity 0.25s}
1041 + .pill:hover::before{opacity:1}
1042 + .pill:hover{transform:translateY(-1px)}
1043 +
1044 + .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}
1045 + .scroll-hint-hidden{opacity:0;pointer-events:none}
1046 + .scroll-hint span{font-family:var(--mono);font-size:10px;letter-spacing:0.2em;color:#B8B5AD;opacity:0.7}
1047 +
1048 + /* ── DIRECTORY ── */
1049 + .directory{background:#f5f5f7;min-height:100vh;display:flex;align-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:48px}
1050 + .directory-inner{display:grid;grid-template-columns:280px 1fr;gap:48px;width:100%;max-width:1200px;margin:0 auto;align-items:center}
1051 + .directory-left{}
1052 + .directory-right{display:flex;flex-direction:column;gap:20px}
1053 + .section-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.2em;color:#6B6860;margin-bottom:10px}
1054 + .section-tag-light{font-family:var(--mono);font-size:12px;letter-spacing:0.2em;color:#B8B5AD}
1055 + .section-title{font-size:clamp(40px,4.5vw,56px);font-weight:900;letter-spacing:-0.025em;line-height:1.05;color:#1C1C1A}
1056 + .section-title-light{font-size:clamp(32px,4vw,46px);font-weight:900;letter-spacing:-0.02em;line-height:1.08;color:#F5F0E8}
1057 +
1058 + /* Door cards */
1059 + .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}
1060 + .door-hovered{transform:translateY(-4px)}
1061 + .door-dark{background:#1C1C1A;color:#F5F0E8;box-shadow:none}
1062 + .door-dark.door-hovered{box-shadow:0 24px 64px rgba(0,0,0,0.2)}
1063 + .door-warm{background:#ffffff;color:#1C1C1A;box-shadow:none}
1064 + .door-warm.door-hovered{box-shadow:0 24px 64px rgba(201,167,81,0.12)}
1065 + .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)}
1066 + .door-content{position:relative}
1067 + .door-header{display:flex;align-items:center;gap:10px;margin-bottom:16px}
1068 + .door-icon{transition:opacity 0.3s}
1069 + .door-badge{display:flex;align-items:center;gap:6px}
1070 + .door-badge-text{font-family:var(--mono);font-size:11px;letter-spacing:0.16em;font-weight:500}
1071 + .door-title{font-size:clamp(28px,3vw,38px);font-weight:900;letter-spacing:-0.02em;line-height:1.08;color:#F5F0E8}
1072 + .door-title-dark{color:#1C1C1A}
1073 + .door-desc{font-family:var(--sans);font-size:17px;color:#6B6860;line-height:1.55;margin-top:10px;max-width:480px}
1074 + .door-desc-light{color:#9B9890}
1075 + .door-pills{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
1076 + .door-pill{font-family:var(--sans);font-size:16px;padding:10px 16px;border-radius:8px;line-height:1.3}
1077 + .door-pill-dark{background:rgba(74,139,110,0.12);color:#8ac4a8;font-weight:500}
1078 + .door-pill-light{background:rgba(166,138,62,0.1);color:#a68a3e;font-weight:500}
1079 + .door-pill-green{background:rgba(74,139,110,0.1);color:#3d7a5e;font-weight:500}
1080 + .door-footer{position:relative;display:flex;justify-content:space-between;align-items:center;margin-top:24px}
1081 + .door-meta{font-family:var(--mono);font-size:11px;letter-spacing:0.1em;color:#4A4843}
1082 + .door-meta-light{color:#B8B5AD}
1083 + .door-cta{font-family:var(--sans);font-size:16px;font-weight:400;transition:color 0.3s}
1084 +
1085 + /* ── CLASIFICADORES ── */
1086 + .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}
1087 + .clas-container{max-width:1100px;margin:0 auto;width:100%;max-height:calc(100vh - 96px);display:flex;flex-direction:column}
1088 + .clas-header-section{margin-bottom:24px;flex-shrink:0}
1089 + .clas-header-row{display:flex;justify-content:space-between;align-items:flex-end}
1090 + .clas-subtitle{font-family:var(--sans);font-size:17px;color:#6B6860;margin-top:10px}
1091 + .clas-ver-todos{font-family:var(--sans);font-size:15px;font-weight:500;color:#1C1C1A;transition:color 0.2s}
1092 + .clas-ver-todos:hover{color:#4A8B6E}
1093 +
1094 + /* Group cards */
1095 + .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}
1096 + /* Parent reacts to child hover */
1097 + .clas-group-card:has(.widget-card:hover){background:#efefef;border-color:rgba(0,0,0,0.06)}
1098 + .clas-group-card:has(.widget-card:hover) > .clas-group-header .clas-group-label{transform:scale(1.05);transform-origin:left}
1099 + /* Parent hover highlights children */
1100 + .clas-group-card:hover .widget-card{background:#fdfdfd;border-color:rgba(0,0,0,0.05)}
1101 + .clas-group-card:hover .clas-subgroup{background:#f5f5f7}
1102 + .gastos-content{display:flex;flex-direction:column;flex:1;gap:10px}
1103 + .gastos-content .clas-subgroup{flex:1;margin-bottom:0}
1104 + .gastos-content > .widgets-row{flex:1}
1105 + .clas-group-header{display:flex;align-items:center;gap:10px;margin-bottom:10px}
1106 + .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}
1107 + .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)}
1108 + .clas-group-sub{font-family:var(--sans);font-size:13px;color:#6B6860;margin-top:2px}
1109 +
1110 + /* Subgroup */
1111 + .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}
1112 + /* Subgroup parent reacts to child hover */
1113 + .clas-subgroup:has(.widget-card:hover){background:#f3f3f5;border-color:rgba(0,0,0,0.05)}
1114 + .clas-subgroup:has(.widget-card:hover) > .clas-subgroup-header .clas-subgroup-label{transform:scale(1.05);transform-origin:left}
1115 + /* Subgroup hover highlights children */
1116 + .clas-subgroup:hover .widget-card{background:#fdfdfd;border-color:rgba(0,0,0,0.05)}
1117 + .clas-subgroup .widgets-row{flex:1}
1118 + .clas-subgroup-header{display:flex;align-items:center;gap:10px;margin-bottom:10px}
1119 + .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}
1120 + .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)}
1121 + .clas-subgroup-tag{font-family:var(--sans);font-size:13px;color:#6B6860;margin-top:2px}
1122 +
1123 + /* Main grid */
1124 + .clas-main-grid{display:grid;grid-template-columns:1fr 2fr;gap:12px;align-items:stretch;flex:1;min-height:0}
1125 + .clas-col{display:flex;flex-direction:column}
1126 + .clas-col .clas-group-card{flex:1}
1127 +
1128 + /* Widget cards */
1129 + .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)}
1130 + .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)}
1131 + .widget-h{display:flex;justify-content:space-between;align-items:center}
1132 + .widget-left{flex:1}
1133 + .widget-right{display:flex;align-items:center;gap:12px;flex-shrink:0}
1134 + .widget-grow{flex:1}
1135 + .widget-sm{padding:12px}
1136 + .widget-full{flex:1}
1137 + .widgets-col{display:flex;flex-direction:column;gap:8px;flex:1}
1138 + .widgets-col .widget-card{flex:1}
1139 + .widgets-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;flex:1}
1140 + .widgets-row .widget-card{display:flex;flex-direction:column;justify-content:space-between}
1141 + .widget-headline{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}
1142 + .widget-num-big{font-family:var(--mono);font-size:24px;font-weight:700;line-height:1}
1143 + .widget-num-gold{color:#a68a3e}
1144 + .widget-num-green{color:#4A8B6E}
1145 + .widget-label{font-family:var(--sans);font-size:14px;font-weight:700;color:#1C1C1A;line-height:1.2}
1146 + .widget-desc{font-family:var(--sans);font-size:14px;color:#6B6860;line-height:1.4;font-weight:400}
1147 + .widget-desc-sm{font-size:13px}
1148 + .widget-footer{display:flex;justify-content:space-between;align-items:center;margin-top:8px}
1149 + .widget-tech{font-family:var(--mono);font-size:9px;letter-spacing:0.1em;color:#9B9890;text-transform:uppercase}
1150 + .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)}
1151 + .arrow-show{color:#1C1C1A;transform:translateX(0)}
1152 +
1153 + /* ── HISTORIAS ── */
1154 + .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}
1155 + .historias .container{width:100%;max-width:1060px;margin:0 auto}
1156 + .hist-header{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:36px}
1157 + .hist-tag-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
1158 + .hist-tag-row svg{stroke:#9B9890}
1159 + .hist-tag-row .section-tag{color:#9B9890}
1160 + .historias .section-title{color:#F5F0E8}
1161 + .hist-subtitle{font-family:var(--sans);font-size:17px;color:#9B9890;margin-top:10px}
1162 + .hist-grid{display:grid;grid-template-columns:repeat(3, 1fr);grid-auto-rows:minmax(120px, auto);gap:16px}
1163 + .hist-cell{grid-row:span 1}
1164 + .hist-tall{grid-row:span 2}
1165 + .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}
1166 + .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)}
1167 + .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)}
1168 + .hist-content{flex:1}
1169 + .hist-tag{font-family:var(--mono);font-size:10px;letter-spacing:0.14em;font-weight:500}
1170 + .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}
1171 + .hist-tall .hist-title{font-size:22px}
1172 + .hist-desc{font-family:var(--sans);font-size:14px;color:#9B9890;line-height:1.5;margin-top:10px}
1173 + .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}
1174 + .hist-arrow{transition:transform 0.3s;display:inline-block}
1175 + .hist-arrow-moved{transform:translateX(3px)}
1176 +
1177 + /* ── VISUALIZACIONES ── */
1178 + .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}
1179 + .visualizaciones .container{width:100%;max-width:1060px;margin:0 auto}
1180 + .vis-header{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:36px}
1181 + .vis-tag-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
1182 + .vis-subtitle{font-family:var(--sans);font-size:17px;color:#6B6860;margin-top:10px}
1183 + .gold-link{font-family:var(--sans);font-size:15px;color:#C9A751;font-weight:600}
1184 + .vis-grid-simple{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px}
1185 + .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}
1186 + .vis-card-hovered{transform:translateY(-2px);box-shadow:0 16px 40px rgba(0,0,0,0.05)}
1187 + .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)}
1188 + .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}
1189 + .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}
1190 + .vis-desc{font-family:var(--sans);font-size:14px;color:#6B6860;line-height:1.5;margin-top:10px}
1191 + .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}
1192 + .vis-arrow{transition:transform 0.3s;display:inline-block}
1193 + .vis-arrow-moved{transform:translateX(3px)}
1194 + .vis-card-link{text-decoration:none;color:inherit}
1195 + .vis-card-disabled{opacity:0.6;cursor:default}
1196 + .vis-card-disabled:hover{transform:none;box-shadow:none}
1197 +
1198 + /* ── MANIFIESTO ── */
1199 + .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}
1200 + .manifiesto-inner{max-width:640px;width:100%;margin:0 auto;position:relative;text-align:center}
1201 + .manifiesto-title{font-size:clamp(36px,4.5vw,56px);font-weight:900;line-height:1.08;letter-spacing:-0.025em;margin-top:18px}
1202 + .manifiesto-text{font-family:var(--sans);font-size:17px;color:#9B9890;margin-top:20px;line-height:1.7}
1203 +
1204 + /* ── DESCARGAS ── */
1205 + .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}
1206 + .descargas-inner{max-width:640px;width:100%;margin:0 auto;text-align:center}
1207 + .descargas-title{font-size:clamp(32px,4vw,48px);font-weight:900;line-height:1.1;letter-spacing:-0.02em;color:#F5F0E8}
1208 + .descargas-formats{display:flex;justify-content:center;gap:12px;margin-top:24px}
1209 + .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}
1210 + .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}
1211 + .descargas-link:hover{color:#d4b45a;gap:10px}
1212 +
1213 + /* ── FOOTER ── */
1214 + footer{background:#1C1C1A;padding:40px 48px;border-top:1px solid #2A2A27}
1215 + .footer-inner{max-width:1060px;margin:0 auto;display:flex;justify-content:space-between;align-items:center}
1216 + .footer-org{font-family:var(--mono);font-size:12px;letter-spacing:0.12em;color:#6B6860}
1217 + .footer-unit{font-family:var(--sans);font-size:13px;color:#6B6860;margin-top:3px}
1218 + .footer-right{font-family:var(--mono);font-size:12px;letter-spacing:0.1em;color:#6B6860}
1219 +
1220 + /* ══════════════════════════════════════════════════════════════════════════
1221 + RESPONSIVE - TABLET (768px)
1222 + ══════════════════════════════════════════════════════════════════════════ */
1223 + @media(max-width:768px){
1224 + .page{scroll-snap-type:y proximity}
1225 + .landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal}
1226 +
1227 + /* Section dots - bottom horizontal bar */
1228 + .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}
1229 + .section-dot{width:8px;height:8px}
1230 + .section-dot-label{display:none}
1231 +
1232 + /* Nav */
1233 + nav{padding:12px 24px}
1234 + .nav-logo{height:60px}
1235 +
1236 + /* Hero */
1237 + .hero{padding:32px 24px}
1238 + .hero-inner{margin-top:0}
1239 + .hero-title{font-size:clamp(36px,8vw,48px)}
1240 + .hero-stats{font-size:11px;line-height:1.6}
1241 + .stat-dot{margin:0 6px}
1242 +
1243 + /* Search bar - mobile compact */
1244 + .search-bar{flex-wrap:nowrap;padding:8px 12px;gap:8px}
1245 + .toggle-desktop{display:none}
1246 + .search-divider{display:none}
1247 + .gear-desktop{display:none}
1248 + .mobile-settings-btn{display:flex}
1249 + .mobile-settings-panel{display:block}
1250 + .search-icon{margin-left:0}
1251 + .search-input-wrap{order:1}
1252 + .search-input-wrap input{font-size:15px;padding:10px 8px}
1253 + .placeholder-desktop{display:none}
1254 + .placeholder-mobile{display:inline;font-size:14px}
1255 +
1256 + /* Search results mobile */
1257 + .search-results{max-height:260px;border-radius:0 0 12px 12px}
1258 + .search-result-item{padding:12px 16px}
1259 + .result-name{font-size:13px}
1260 + .result-meta{flex-direction:column;align-items:flex-end;gap:4px}
1261 + .result-code{font-size:9px}
1262 + .result-type{font-size:8px}
1263 + .pills{gap:6px}
1264 + .pill{font-size:11px;padding:4px 10px}
1265 + .scroll-hint{bottom:20px}
1266 +
1267 + /* Directory */
1268 + .directory{padding:48px 24px}
1269 + .directory-inner{grid-template-columns:1fr;gap:32px}
1270 + .directory-left{text-align:center}
1271 + .section-title{font-size:clamp(32px,7vw,42px)}
1272 + .door{padding:28px 24px 24px;min-height:auto}
1273 + .door-title{font-size:clamp(24px,5vw,32px)}
1274 + .door-pills{gap:6px}
1275 + .door-pill{font-size:14px;padding:8px 12px}
1276 + .door-meta{font-size:10px}
1277 +
1278 + /* Clasificadores */
1279 + .clasificadores{padding:32px 20px;height:auto;min-height:100vh;overflow-y:auto}
1280 + .clas-container{max-height:none}
1281 + .clas-header-row{flex-direction:column;align-items:flex-start;gap:12px}
1282 + .clas-ver-todos{align-self:flex-start}
1283 + .clas-main-grid{grid-template-columns:1fr;gap:12px}
1284 + .widgets-row{grid-template-columns:1fr}
1285 + .widget-h{flex-direction:column;align-items:flex-start;gap:12px}
1286 + .widget-right{width:100%;justify-content:space-between}
1287 + .widget-num-big{font-size:22px}
1288 + .clas-group-icon,.clas-subgroup-icon{width:28px;height:28px}
1289 +
1290 + /* Historias */
1291 + .historias{padding:60px 24px}
1292 + .hist-header{flex-direction:column;align-items:flex-start;gap:16px}
1293 + .hist-grid{grid-template-columns:1fr 1fr;grid-auto-rows:auto;gap:12px}
1294 + .hist-tall{grid-row:span 1}
1295 + .hist-card{padding:20px 18px 18px}
1296 + .hist-title{font-size:16px}
1297 + .hist-tall .hist-title{font-size:18px}
1298 + .hist-desc{font-size:13px}
1299 +
1300 + /* Visualizaciones */
1301 + .visualizaciones{padding:60px 24px}
1302 + .vis-header{flex-direction:column;align-items:flex-start;gap:16px}
1303 + .vis-grid-simple{grid-template-columns:1fr 1fr;gap:12px}
1304 + .vis-card{padding:20px 18px 18px}
1305 + .vis-title{font-size:16px}
1306 +
1307 + /* Manifiesto */
1308 + .manifiesto{padding:60px 24px}
1309 + .manifiesto-title{font-size:clamp(28px,6vw,40px)}
1310 +
1311 + /* Descargas */
1312 + .descargas{padding:60px 24px}
1313 + .descargas-title{font-size:clamp(28px,6vw,40px)}
1314 + .descargas-formats{gap:8px}
1315 + .format-pill{font-size:12px;padding:6px 12px}
1316 +
1317 + /* Footer */
1318 + footer{padding:32px 24px}
1319 + .footer-inner{flex-direction:column;gap:12px;text-align:center}
1320 + .footer-org,.footer-right{font-size:10px}
1321 + }
1322 +
1323 + /* ══════════════════════════════════════════════════════════════════════════
1324 + RESPONSIVE - MOBILE (480px)
1325 + ══════════════════════════════════════════════════════════════════════════ */
1326 + @media(max-width:480px){
1327 + /* Section dots - bottom bar smaller */
1328 + .section-dots{bottom:16px;gap:8px;padding:10px 16px;border-radius:20px}
1329 + .section-dot{width:7px;height:7px}
1330 +
1331 + /* Nav */
1332 + nav{padding:10px 16px}
1333 + .nav-logo{height:48px}
1334 +
1335 + /* Hero */
1336 + .hero{padding:24px 16px}
1337 + .hero-title{font-size:clamp(32px,9vw,40px)}
1338 + .hero-sub{font-size:15px}
1339 + .hero-stats{font-size:10px;display:flex;flex-wrap:wrap;justify-content:center;gap:4px 0}
1340 + .stat-dot{margin:0 4px}
1341 +
1342 + /* Search */
1343 + .search-wrap{padding:0}
1344 + .mobile-settings-btn{width:36px;height:36px}
1345 + .mobile-meta-item{padding:10px 12px;font-size:12px}
1346 + .search-results{max-height:220px}
1347 + .search-result-item{padding:10px 14px}
1348 + .result-name{font-size:12px}
1349 + .result-sigla{font-size:10px}
1350 + .pills-label{display:none}
1351 + .pill{font-size:10px;padding:4px 8px}
1352 + .scroll-hint{display:none}
1353 +
1354 + /* Directory */
1355 + .directory{padding:40px 16px}
1356 + .directory-inner{gap:24px}
1357 + .section-tag{font-size:10px}
1358 + .section-title{font-size:clamp(28px,8vw,36px)}
1359 + .door{padding:24px 20px 20px;border-radius:18px}
1360 + .door-icon svg{width:22px;height:22px}
1361 + .door-title{font-size:clamp(22px,6vw,28px)}
1362 + .door-pill{font-size:13px;padding:7px 10px}
1363 + .door-footer{flex-direction:column;align-items:flex-start;gap:8px}
1364 + .door-meta{font-size:9px}
1365 + .door-cta{font-size:14px}
1366 +
1367 + /* Clasificadores */
1368 + .clasificadores{padding:24px 16px}
1369 + .clas-header-section{margin-bottom:16px}
1370 + .section-title{font-size:clamp(24px,6vw,32px)}
1371 + .clas-subtitle{font-size:14px}
1372 + .clas-group-card{padding:12px;border-radius:14px;margin-bottom:10px}
1373 + .clas-subgroup{padding:10px;border-radius:10px}
1374 + .clas-group-header,.clas-subgroup-header{margin-bottom:8px}
1375 + .clas-group-label,.clas-subgroup-label{font-size:10px}
1376 + .clas-group-sub{font-size:12px}
1377 + .widget-card{padding:12px;border-radius:10px}
1378 + .widget-num-big{font-size:20px}
1379 + .widget-label{font-size:13px}
1380 + .widget-desc{font-size:12px}
1381 + .widget-tech{font-size:8px}
1382 +
1383 + /* Historias */
1384 + .historias{padding:48px 16px}
1385 + .hist-subtitle{font-size:14px}
1386 + .hist-grid{grid-template-columns:1fr;gap:10px}
1387 + .hist-card{padding:18px 16px 16px;border-radius:14px}
1388 + .hist-tag{font-size:9px}
1389 + .hist-title{font-size:16px}
1390 + .hist-desc{font-size:12px;margin-top:8px}
1391 + .hist-cta{font-size:13px;margin-top:14px}
1392 +
1393 + /* Visualizaciones */
1394 + .visualizaciones{padding:48px 16px}
1395 + .vis-subtitle{font-size:14px}
1396 + .vis-grid-simple{grid-template-columns:1fr;gap:10px}
1397 + .vis-card{padding:18px 16px 16px;border-radius:14px}
1398 + .vis-type{font-size:9px;padding:4px 8px}
1399 + .vis-title{font-size:16px}
1400 + .vis-desc{font-size:12px}
1401 + .vis-cta{font-size:13px;margin-top:14px}
1402 +
1403 + /* Manifiesto */
1404 + .manifiesto{padding:48px 16px}
1405 + .section-tag-light{font-size:10px}
1406 + .manifiesto-title{font-size:clamp(24px,7vw,32px)}
1407 +
1408 + /* Descargas */
1409 + .descargas{padding:48px 16px}
1410 + .descargas-title{font-size:clamp(24px,7vw,32px)}
1411 + .descargas-formats{flex-wrap:wrap;gap:6px}
1412 + .format-pill{font-size:11px;padding:5px 10px}
1413 + .descargas-link{font-size:14px;margin-top:24px}
1414 +
1415 + /* Footer */
1416 + footer{padding:24px 16px}
1417 + .footer-org{font-size:9px;letter-spacing:0.08em}
1418 + .footer-right{font-size:9px}
1419 + }
1420 +</style>
...\ No newline at end of file ...\ No newline at end of file
1 +<script>
2 + import { onMount } from 'svelte';
3 +
4 + // Estado base
5 + let mounted = false;
6 + let searchFocused = false;
7 + let searchVal = '';
8 + let searchInput;
9 + let isLoading = false;
10 + let results = null;
11 + let error = null;
12 +
13 + // Ordenamiento
14 + let sortBy = 'amount';
15 + let sortOrder = 'desc';
16 +
17 + // Paginación
18 + let currentPage = 1;
19 + let isLoadingMore = false;
20 + let allHits = [];
21 +
22 + function toggleSort(field) {
23 + if (sortBy === field) {
24 + sortOrder = sortOrder === 'desc' ? 'asc' : 'desc';
25 + } else {
26 + sortBy = field;
27 + sortOrder = 'desc';
28 + }
29 + }
30 +
31 + $: sortedHits = allHits.length > 0 ? [...allHits].sort((a, b) => {
32 + let valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
33 + let valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
34 + return sortOrder === 'desc' ? valB - valA : valA - valB;
35 + }) : [];
36 +
37 + $: hasMoreResults = results && allHits.length < results.found;
38 +
39 + // Modo de búsqueda
40 + let searchMode = 'gastos'; // 'gastos' | 'ingresos'
41 + let searchSubmode = 'programas'; // 'programas' | 'clasificadores'
42 + let selectedClassifiers = [];
43 +
44 + const CLASIFICADORES_GASTOS = [
45 + { id: 'finfun', label: 'Finalidad y función' },
46 + { id: 'objeto', label: 'Objetos de gasto' },
47 + { id: 'acteco', label: 'Actividad económica' },
48 + { id: 'entidad', label: 'Entidades' },
49 + { id: 'geografico', label: 'Geográfico', disabled: true },
50 + ];
51 +
52 + const CLASIFICADORES_INGRESOS = [
53 + { id: 'rubro', label: 'Rubro', disabled: true },
54 + { id: 'organismo', label: 'Organismo', disabled: true },
55 + { id: 'fuente', label: 'Fuente', disabled: true },
56 + { id: 'entidad', label: 'Entidades' },
57 + { id: 'geografico',label: 'Geográfico', disabled: true },
58 + ];
59 +
60 + $: activeClasificadores = searchMode === 'gastos' ? CLASIFICADORES_GASTOS : CLASIFICADORES_INGRESOS;
61 +
62 + function toggleClassifier(id) {
63 + if (selectedClassifiers.includes(id)) {
64 + selectedClassifiers = selectedClassifiers.filter(c => c !== id);
65 + } else {
66 + selectedClassifiers = [...selectedClassifiers, id];
67 + }
68 + }
69 +
70 + function setMode(mode) {
71 + searchMode = mode;
72 + selectedClassifiers = [];
73 + if (mode === 'gastos') {
74 + searchSubmode = 'programas';
75 + } else {
76 + searchSubmode = 'clasificadores';
77 + }
78 + }
79 +
80 + function setSubmode(sub) {
81 + searchSubmode = sub;
82 + selectedClassifiers = [];
83 + }
84 +
85 + // Placeholder dinámico
86 + $: placeholderText = getPlaceholderText(searchMode, searchSubmode, selectedClassifiers);
87 +
88 + function getPlaceholderText(mode, submode, classifiers) {
89 + if (mode === 'gastos') {
90 + if (submode === 'programas') return 'Buscar en programas y proyectos...';
91 + if (classifiers.length === 0) return 'Selecciona al menos un clasificador...';
92 + if (classifiers.length === CLASIFICADORES_GASTOS.length) return 'Buscar en todos los clasificadores de gasto...';
93 + return `Buscar en ${classifiers.length} clasificador${classifiers.length > 1 ? 'es' : ''} de gasto...`;
94 + } else {
95 + if (classifiers.length === 0) return 'Selecciona al menos un clasificador de ingreso...';
96 + return `Buscar en ${classifiers.length} clasificador${classifiers.length > 1 ? 'es' : ''} de ingreso...`;
97 + }
98 + }
99 +
100 + // Debounce
101 + let debounceTimer;
102 + let hintTimer;
103 + let showMinLengthHint = false;
104 + const DEBOUNCE_MS = 300;
105 + const HINT_DELAY_MS = 1500;
106 +
107 + const POBLACION = 12000000;
108 + const API_BASE = '/api/search';
109 +
110 + // Hint diario
111 + let dailyData = null;
112 + let dailyLoading = true;
113 +
114 + async function fetchDaily() {
115 + try {
116 + const res = await fetch('/api/daily-summary');
117 + if (res.ok) dailyData = await res.json();
118 + } catch {
119 + // silencioso — es un hint, no crítico
120 + } finally {
121 + dailyLoading = false;
122 + }
123 + }
124 +
125 + // Ejemplos
126 + const EXAMPLES = [
127 + { label: 'Vía férrea Bulo Bulo', color: 'rgba(201,167,81,0.12)', textColor: '#d4b962' },
128 + { label: 'Viru Viru', color: 'rgba(150,155,175,0.10)', textColor: '#a0a5b8' },
129 + { label: 'Cancha de fútbol', color: 'rgba(180,130,100,0.10)', textColor: '#b8947a' },
130 + ];
131 +
132 + // Navegación
133 + const SECTIONS_NAV = [
134 + { id: 'hero', label: 'Inicio' },
135 + { id: 'datos', label: 'Datos' },
136 + { id: 'clasificadores', label: 'Clasificadores' },
137 + { id: 'historias', label: 'Historias' },
138 + { id: 'visualizaciones',label: 'Visualizaciones' },
139 + ];
140 + let currentSection = 'hero';
141 +
142 + function scrollToSection(id) {
143 + document.querySelector(`[data-section="${id}"]`)?.scrollIntoView({ behavior: 'smooth' });
144 + }
145 +
146 + onMount(() => {
147 + setTimeout(() => { mounted = true; }, 80);
148 + fetchDaily();
149 +
150 + const isMobile = window.innerWidth < 768;
151 + if (!isMobile) setTimeout(() => searchInput?.focus(), 300);
152 +
153 + const sections = document.querySelectorAll('[data-section]');
154 + const observer = new IntersectionObserver(entries => {
155 + entries.forEach(e => { if (e.isIntersecting) currentSection = e.target.dataset.section; });
156 + }, { threshold: 0.5 });
157 + sections.forEach(s => observer.observe(s));
158 +
159 + return () => observer.disconnect();
160 + });
161 +
162 + function searchExample(label) {
163 + searchVal = label;
164 + performSearch(label);
165 + searchInput?.focus();
166 + }
167 +
168 + async function handleInput(e) {
169 + searchVal = e.target.value;
170 + clearTimeout(debounceTimer);
171 + clearTimeout(hintTimer);
172 + showMinLengthHint = false;
173 +
174 + if (searchVal.length < 3) {
175 + results = null;
176 + error = null;
177 + if (searchVal.length > 0) {
178 + hintTimer = setTimeout(() => { showMinLengthHint = true; }, HINT_DELAY_MS);
179 + }
180 + return;
181 + }
182 +
183 + const needsClassifiers = searchMode === 'ingresos' ||
184 + (searchMode === 'gastos' && searchSubmode === 'clasificadores');
185 + if (needsClassifiers && selectedClassifiers.length === 0) {
186 + results = null;
187 + error = null;
188 + return;
189 + }
190 +
191 + debounceTimer = setTimeout(() => performSearch(searchVal), DEBOUNCE_MS);
192 + }
193 +
194 + async function performSearch(query, page = 1) {
195 + if (page === 1) {
196 + isLoading = true;
197 + allHits = [];
198 + sortBy = 'amount';
199 + sortOrder = 'desc';
200 + } else {
201 + isLoadingMore = true;
202 + }
203 + error = null;
204 + currentPage = page;
205 +
206 + try {
207 + const isClassSearch = searchMode === 'ingresos' ||
208 + (searchMode === 'gastos' && searchSubmode === 'clasificadores');
209 + const params = new URLSearchParams({
210 + q: query,
211 + is_class: isClassSearch ? 'true' : 'false',
212 + mode: searchMode,
213 + page: page.toString()
214 + });
215 + if (isClassSearch && selectedClassifiers.length > 0) {
216 + params.set('class_', selectedClassifiers.join(','));
217 + }
218 +
219 + const res = await fetch(`${API_BASE}?${params}`);
220 + if (!res.ok) throw new Error(`Error ${res.status}`);
221 + const data = await res.json();
222 + results = data;
223 + allHits = page === 1 ? data.hits : [...allHits, ...data.hits];
224 + } catch (err) {
225 + console.error(err);
226 + error = 'Error al buscar. Intenta de nuevo.';
227 + if (page === 1) { results = null; allHits = []; }
228 + }
229 +
230 + isLoading = false;
231 + isLoadingMore = false;
232 + }
233 +
234 + function loadMore() {
235 + if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
236 + }
237 +
238 + function handleKeydown(e) {
239 + if (e.key === 'Escape') { clearSearch(); searchInput?.blur(); }
240 + }
241 +
242 + function clearSearch() {
243 + searchVal = '';
244 + results = null;
245 + allHits = [];
246 + currentPage = 1;
247 + error = null;
248 + showMinLengthHint = false;
249 + clearTimeout(debounceTimer);
250 + clearTimeout(hintTimer);
251 + searchInput?.focus();
252 + }
253 +
254 + // Helpers
255 + function extractAfterHyphen(text) {
256 + if (!text) return '';
257 + const parts = text.split(' - ');
258 + return parts.length > 1 ? parts.slice(1).join(' - ').trim() : text;
259 + }
260 +
261 + function parseMetadatos(meta) {
262 + if (!meta) return {};
263 + if (typeof meta === 'object') return meta;
264 + try { return JSON.parse(meta); } catch { return {}; }
265 + }
266 +
267 + function isClassifierSearch(data) { return data?.hits?.[0]?.document?.is_class === true; }
268 + function isEntidadSearch(data) { return data?.hits?.[0]?.document?.class_ === 'entidad'; }
269 +
270 + function getStats(data) {
271 + if (!data?.hits?.length) return null;
272 + const hits = data.hits;
273 + const isClassifier = isClassifierSearch(data);
274 + const montoTotal = hits.reduce((s, h) => s + (h.document.devengado || 0), 0);
275 +
276 + if (isClassifier) {
277 + const isEntidad = isEntidadSearch(data);
278 + const entidadesSet = new Set();
279 + const subareasCount = {};
280 + hits.forEach(h => {
281 + const m = parseMetadatos(h.document.metadatos);
282 + if (m.entidad) entidadesSet.add(m.entidad);
283 + if (isEntidad) {
284 + const sub = extractAfterHyphen(m.entidad_desc_subarea);
285 + if (sub) subareasCount[sub] = (subareasCount[sub] || 0) + 1;
286 + }
287 + });
288 + return { found: data.found, montoTotal, perCapita: montoTotal / POBLACION,
289 + numEntidades: entidadesSet.size, subareasCount: isEntidad ? subareasCount : null,
290 + isClassifier: true, classType: hits[0]?.document?.class_ || null };
291 + } else {
292 + const entidadesSet = new Set();
293 + hits.forEach(h => { const m = parseMetadatos(h.document.metadatos); if (m.entidad) entidadesSet.add(m.entidad); });
294 + const years = hits.map(h => h.document.gestion).filter(Boolean);
295 + const minYear = Math.min(...years);
296 + const maxYear = Math.max(...years);
297 + return { found: data.found, montoTotal, perCapita: montoTotal / POBLACION,
298 + numEntidades: entidadesSet.size,
299 + yearRange: minYear === maxYear ? `${minYear}` : `${minYear} – ${maxYear}`,
300 + isClassifier: false };
301 + }
302 + }
303 +
304 + function formatMonto(num) {
305 + if (num >= 1e9) return `${(num/1e9).toFixed(1)} mil millones de Bs`;
306 + if (num >= 1e6) return `${(num/1e6).toFixed(1)} millones de Bs`;
307 + if (num >= 1e3) return `${(num/1e3).toFixed(0)} mil Bs`;
308 + return `${num.toFixed(0)} Bs`;
309 + }
310 +
311 + function formatMontoFull(num) {
312 + return `${num.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} Bs`;
313 + }
314 +
315 + function generateCodigo(hit) {
316 + const m = parseMetadatos(hit.document.metadatos);
317 + return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`;
318 + }
319 +
320 + $: stats = results ? getStats(results) : null;
321 +
322 + $: {
323 + const _mode = searchMode;
324 + const _submode = searchSubmode;
325 + const _classifiers = selectedClassifiers;
326 + if (searchVal.length >= 3 && mounted) {
327 + clearTimeout(debounceTimer);
328 + const needsClassifiers = _mode === 'ingresos' || (_mode === 'gastos' && _submode === 'clasificadores');
329 + if (!needsClassifiers || _classifiers.length > 0) {
330 + debounceTimer = setTimeout(() => performSearch(searchVal), 150);
331 + } else {
332 + results = null;
333 + }
334 + }
335 + }
336 +
337 + // ¿Mostrar chips de clasificadores?
338 + $: showClassifierChips = searchMode === 'ingresos' ||
339 + (searchMode === 'gastos' && searchSubmode === 'clasificadores');
340 +
341 + // ¿Hay búsqueda activa?
342 + $: isSearching = searchVal.length >= 3;
343 +</script>
344 +
345 +<svelte:head>
346 + <title>Buscador | Presupuesto Abierto</title>
347 +</svelte:head>
348 +
349 +<div class="page" class:mounted>
350 +
351 + <!-- Dots de navegación -->
352 + <nav class="section-dots">
353 + {#each SECTIONS_NAV as s}
354 + <button
355 + class="section-dot"
356 + class:section-dot-active={currentSection === s.id}
357 + on:click={() => scrollToSection(s.id)}
358 + aria-label={s.label}
359 + >
360 + <span class="section-dot-label">{s.label}</span>
361 + </button>
362 + {/each}
363 + </nav>
364 +
365 + <!-- Hero -->
366 + <main data-section="hero">
367 +
368 + <div class="nav-logo">
369 + <img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
370 + </div>
371 +
372 + <button class="nav-menu-btn" aria-label="Menú">
373 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
374 + <line x1="3" y1="6" x2="21" y2="6"/>
375 + <line x1="3" y1="12" x2="21" y2="12"/>
376 + <line x1="3" y1="18" x2="21" y2="18"/>
377 + </svg>
378 + </button>
379 +
380 + <div class="search-container anim" style="--d:200ms">
381 +
382 + <!-- Título -->
383 + <h1 class="hero-title" class:hero-collapsed={allHits.length > 0}>
384 + Presupuesto <span class="gold">Abierto</span>
385 + </h1>
386 + <p class="hero-sub" class:hero-collapsed={allHits.length > 0}>
387 + <a href="/vista-diaria" class="hero-sub-link">Información diaria</a> e histórica de todo el Estado Boliviano.
388 + </p>
389 +
390 + <!-- Buscador -->
391 + <div class="search-wrap">
392 + <div class="search-bar" class:search-focused={searchFocused} class:has-results={isSearching}>
393 + <svg class="search-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
394 + stroke={searchFocused ? '#D4B254' : '#8C8880'} stroke-width="2" stroke-linecap="round">
395 + <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
396 + </svg>
397 +
398 + <div class="search-input-wrap">
399 + <input
400 + type="text"
401 + bind:this={searchInput}
402 + bind:value={searchVal}
403 + on:input={handleInput}
404 + on:keydown={handleKeydown}
405 + on:focus={() => searchFocused = true}
406 + on:blur={() => searchFocused = false}
407 + placeholder=""
408 + />
409 + {#if !searchVal}
410 + <div class="search-placeholder">
411 + <span class="blink-cursor" class:cursor-hidden={searchFocused}></span>
412 + <span class="placeholder-text">{placeholderText}</span>
413 + </div>
414 + {/if}
415 + {#if searchVal && !isLoading}
416 + <button class="search-clear" on:click={clearSearch}>
417 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
418 + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
419 + </svg>
420 + </button>
421 + {/if}
422 + {#if isLoading}
423 + <div class="search-loading"><span class="loading-dot"></span></div>
424 + {/if}
425 + </div>
426 + </div>
427 +
428 + <!-- ─── FILTROS INLINE ─── -->
429 + <div class="filters-row" class:filters-hidden={isSearching}>
430 +
431 + <!-- Fila 1: Gastos / Ingresos -->
432 + <div class="filter-group">
433 + <button
434 + class="filter-chip filter-chip-mode"
435 + class:filter-chip-active={searchMode === 'gastos'}
436 + on:click={() => setMode('gastos')}
437 + >
438 + Gastos
439 + </button>
440 + <button
441 + class="filter-chip filter-chip-mode"
442 + class:filter-chip-active={searchMode === 'ingresos'}
443 + on:click={() => setMode('ingresos')}
444 + >
445 + Ingresos
446 + </button>
447 +
448 + <span class="filter-divider"></span>
449 +
450 + <!-- Sub-modo (solo cuando Gastos está activo) -->
451 + {#if searchMode === 'gastos'}
452 + <button
453 + class="filter-chip"
454 + class:filter-chip-active={searchSubmode === 'programas'}
455 + on:click={() => setSubmode('programas')}
456 + >
457 + Programas y proyectos
458 + </button>
459 + <button
460 + class="filter-chip"
461 + class:filter-chip-active={searchSubmode === 'clasificadores'}
462 + on:click={() => setSubmode('clasificadores')}
463 + >
464 + Clasificadores
465 + </button>
466 + {/if}
467 + </div>
468 +
469 + <!-- Fila 2: Clasificadores (cuando aplica) -->
470 + {#if showClassifierChips}
471 + <div class="filter-group filter-group-classifiers">
472 + {#each activeClasificadores as clf}
473 + <button
474 + class="filter-chip filter-chip-classifier"
475 + class:filter-chip-active={selectedClassifiers.includes(clf.id)}
476 + class:filter-chip-disabled={clf.disabled}
477 + on:click={() => { if (!clf.disabled) toggleClassifier(clf.id); }}
478 + disabled={clf.disabled}
479 + >
480 + {clf.label}
481 + {#if clf.disabled}
482 + <span class="chip-soon">próx.</span>
483 + {/if}
484 + </button>
485 + {/each}
486 + </div>
487 + {/if}
488 + </div>
489 +
490 + <!-- ─── ÁREA DE RESULTADOS / EJEMPLOS / HINT ─── -->
491 + <div class="results-area">
492 +
493 + <!-- Ejemplos -->
494 + <div class="examples" class:examples-hidden={isSearching}>
495 + <span class="examples-label">Prueba con:</span>
496 + {#each EXAMPLES as ex}
497 + <button
498 + class="example-chip"
499 + style="background:{ex.color}; color:{ex.textColor}"
500 + on:click={() => searchExample(ex.label)}
501 + >
502 + {ex.label}
503 + </button>
504 + {/each}
505 + </div>
506 +
507 + <!-- Hint diario -->
508 + {#if !isSearching && !dailyLoading}
509 + <a
510 + href="/vista-diaria"
511 + class="daily-hint"
512 + class:daily-hint-visible={!isSearching}
513 + >
514 + <span class="daily-dot"></span>
515 + <span class="daily-text">
516 + {#if dailyData}
517 + Ayer el Estado ejecutó <strong>{formatMonto(dailyData.monto)}</strong>
518 + {:else}
519 + Ver el resumen de ejecución de ayer
520 + {/if}
521 + </span>
522 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
523 + <path d="M5 12h14M12 5l7 7-7 7"/>
524 + </svg>
525 + </a>
526 + {/if}
527 +
528 + <!-- Estados de búsqueda -->
529 + {#if showMinLengthHint && searchVal.length > 0 && searchVal.length < 3}
530 + <div class="results-content">
531 + <div class="results-hint">Escribe al menos 3 letras para buscar</div>
532 + </div>
533 + {:else if error && !results}
534 + <div class="results-content">
535 + <div class="results-error">{error}</div>
536 + </div>
537 + {:else if isLoading && !results}
538 + <div class="results-content">
539 + <div class="results-loading">Buscando...</div>
540 + </div>
541 + {:else if allHits.length > 0}
542 + <div class="results-content results-content-filled">
543 +
544 + {#if stats}
545 + <div class="results-summary">
546 + <div class="summary-row">
547 + <div class="summary-left">
548 + <div class="summary-header">
549 + <span class="summary-count">{sortedHits.length}</span>
550 + <span class="summary-header-text">de {stats.found.toLocaleString()} resultados</span>
551 + {#if isLoading}<span class="updating-dot"></span>{/if}
552 + </div>
553 + <div class="summary-meta">
554 + <span class="summary-tag summary-tag-gold">
555 + {formatMonto(stats.montoTotal)} <span class="summary-period">· 2006–2025</span>
556 + </span>
557 + {#if stats.isClassifier}
558 + {#if stats.subareasCount}
559 + <span class="summary-tag summary-tag-entities">{stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
560 + <span class="summary-arrow">→</span>
561 + {#each Object.entries(stats.subareasCount).sort((a,b) => b[1]-a[1]) as [sub, count]}
562 + <span class="summary-tag summary-tag-subarea">{sub}: {count}</span>
563 + {/each}
564 + {:else}
565 + <span class="summary-tag">{stats.found} resultados en {stats.classType}</span>
566 + {/if}
567 + {:else}
568 + <span class="summary-tag">{stats.numEntidades} {stats.numEntidades === 1 ? 'institución' : 'instituciones'}</span>
569 + {#if stats.yearRange}
570 + <span class="summary-tag">{stats.yearRange}</span>
571 + {/if}
572 + {/if}
573 + </div>
574 + </div>
575 + <div class="sort-buttons">
576 + {#if !stats.isClassifier}
577 + <button class="sort-btn" class:sort-btn-active={sortBy === 'year'} on:click={() => toggleSort('year')}>
578 + Año
579 + {#if sortBy === 'year'}
580 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
581 + {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
582 + {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
583 + </svg>
584 + {/if}
585 + </button>
586 + {/if}
587 + <button class="sort-btn" class:sort-btn-active={sortBy === 'amount'} on:click={() => toggleSort('amount')}>
588 + Monto
589 + {#if sortBy === 'amount'}
590 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
591 + {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
592 + {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
593 + </svg>
594 + {/if}
595 + </button>
596 + </div>
597 + </div>
598 + </div>
599 + {/if}
600 +
601 + <div class="results-list">
602 + {#each sortedHits as hit, i}
603 + {@const meta = parseMetadatos(hit.document.metadatos)}
604 + {@const isEntidadClass = hit.document.class_ === 'entidad'}
605 + {@const isDA = isEntidadClass && meta.da && meta.entidad_desc_entidad}
606 + {@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
607 + {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
608 + {@const isClassResult = hit.document.is_class === true}
609 + <a
610 + href={isEntidadClass
611 + ? `/entidad/${meta.entidad}`
612 + : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
613 + class="result-card"
614 + style="--delay: {i * 30}ms"
615 + >
616 + <div class="result-content">
617 + <div class="result-text">
618 + {#if hit.highlights?.[0]?.snippet}
619 + {@html hit.highlights[0].snippet}
620 + {:else}
621 + {hit.document.texto}
622 + {/if}
623 + </div>
624 + <div class="result-meta">
625 + {#if isEntidadClass}
626 + {#if isDA}<span class="result-da-badge">DA</span><span class="result-parent">de {parentEntity}</span>{/if}
627 + {#if subarea}<span class="result-subarea">{subarea}</span>{/if}
628 + {:else if isClassResult}
629 + <span class="result-class-type">{hit.document.class_}</span>
630 + {:else}
631 + <span class="result-year">{hit.document.gestion}</span>
632 + {/if}
633 + <span class="result-monto">{formatMontoFull(hit.document.devengado)}</span>
634 + </div>
635 + </div>
636 + <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">
637 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
638 + <polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
639 + </svg>
640 + </a>
641 + {/each}
642 + </div>
643 +
644 + {#if hasMoreResults}
645 + <div class="load-more-container">
646 + <button class="load-more-btn" on:click={loadMore} disabled={isLoadingMore}>
647 + {#if isLoadingMore}
648 + <span class="loading-dot"></span> Cargando...
649 + {:else}
650 + Cargar más ({allHits.length} de {results.found})
651 + {/if}
652 + </button>
653 + </div>
654 + {/if}
655 + </div>
656 +
657 + {:else if results && results.hits?.length === 0}
658 + <div class="results-content">
659 + <div class="results-empty">Sin resultados para "{searchVal}"</div>
660 + </div>
661 + {/if}
662 +
663 + <div class="area-spacer" class:area-spacer-hidden={allHits.length > 0}></div>
664 + </div>
665 + </div>
666 + </div>
667 +
668 + <!-- Scroll hint -->
669 + <div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching}>
670 + <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">
671 + <rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
672 + </svg>
673 + <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">
674 + <path d="M6 9l6 6 6-6"/>
675 + </svg>
676 + <span>Explora la página</span>
677 + </div>
678 +
679 + </main>
680 +</div>
681 +
682 +<style>
683 + @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&display=swap');
684 +
685 + :global(*) { margin: 0; padding: 0; box-sizing: border-box; }
686 + :global(html), :global(body) { overflow-x: hidden; }
687 +
688 + .page {
689 + --serif: 'Qanelas', system-ui, sans-serif;
690 + --sans: 'Qanelas', system-ui, sans-serif;
691 + --mono: 'JetBrains Mono', monospace;
692 + --gold: #D4B254;
693 + --gold-bright: #EAD06A;
694 + --gold-soft: rgba(212,178,84,0.15);
695 + --green: #4A8B6E;
696 + --bg: #1A1A18;
697 + --bg-elevated: #232321;
698 + --bg-surface: #2A2A28;
699 + --text: #F7F3EB;
700 + --text-secondary: #E0DCD4;
701 + --text-muted: #B5B1A9;
702 + --text-dim: #8C8880;
703 +
704 + font-family: var(--sans);
705 + background: var(--bg);
706 + color: var(--text);
707 + height: 100vh;
708 + max-height: 100vh;
709 + overflow: hidden;
710 + display: flex;
711 + flex-direction: column;
712 + -webkit-font-smoothing: antialiased;
713 + -moz-osx-font-smoothing: grayscale;
714 + text-rendering: optimizeLegibility;
715 + }
716 +
717 + @keyframes pulse { 0%,100% { opacity:0.4; } 50% { opacity:1; } }
718 + @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
719 +
720 + /* ── Dots ── */
721 + .section-dots {
722 + position: fixed; right: 24px; top: 50%; transform: translateY(-50%);
723 + z-index: 100; display: flex; flex-direction: column; gap: 12px;
724 + background: rgba(255,255,255,0.06); backdrop-filter: blur(8px);
725 + padding: 12px 8px; border-radius: 20px;
726 + }
727 + .section-dot {
728 + width:10px; height:10px; border-radius:50%;
729 + border:1.5px solid rgba(180,177,169,0.4);
730 + background:rgba(180,177,169,0.15);
731 + cursor:pointer; transition:all 0.3s; padding:0; position:relative;
732 + }
733 + .section-dot:hover { border-color:rgba(180,177,169,0.6); background:rgba(180,177,169,0.3); transform:scale(1.2); }
734 + .section-dot-active { background:var(--gold); border-color:var(--gold); }
735 + .section-dot-label {
736 + position:absolute; right:20px; top:50%; transform:translateY(-50%);
737 + font-family:var(--mono); font-size:10px; letter-spacing:0.05em;
738 + color:var(--text-dim); white-space:nowrap; opacity:0; transition:opacity 0.2s; pointer-events:none;
739 + }
740 + .section-dot:hover .section-dot-label { opacity:1; }
741 +
742 + /* ── Anim ── */
743 + .anim { opacity:0; transform:translateY(18px); transition:all 0.8s cubic-bezier(0.16,1,0.3,1); transition-delay:var(--d,0ms); }
744 + .mounted .anim { opacity:1; transform:translateY(0); }
745 +
746 + /* ── Nav ── */
747 + .nav-logo { position:absolute; top:24px; left:32px; z-index:50; opacity:0.75; pointer-events:none; }
748 + .nav-logo img { height:100px; width:auto; object-fit:contain; }
749 + .nav-menu-btn {
750 + position:absolute; top:24px; right:24px; z-index:50;
751 + background:none; border:none; cursor:pointer; color:var(--text-dim);
752 + opacity:0.6; padding:8px; border-radius:8px; transition:all 0.2s;
753 + }
754 + .nav-menu-btn:hover { opacity:1; color:var(--text); background:rgba(255,255,255,0.05); }
755 +
756 + /* ── Main ── */
757 + main {
758 + flex:1; display:flex; flex-direction:column; align-items:center;
759 + justify-content:flex-start; padding:28vh 48px 0;
760 + position:relative; overflow:hidden;
761 + }
762 +
763 + .search-container { width:100%; max-width:820px; text-align:center; position:relative; }
764 +
765 + /* ── Título ── */
766 + .hero-title {
767 + font-size:clamp(40px,5.5vw,64px); font-weight:900;
768 + line-height:1.05; letter-spacing:-0.035em; color:var(--text);
769 + margin-top:40px; transition:all 0.35s cubic-bezier(0.16,1,0.3,1);
770 + }
771 + .hero-title.hero-collapsed { opacity:0; height:0; overflow:hidden; margin:0; }
772 +
773 + .hero-sub {
774 + font-size:clamp(16px,1.6vw,18px); color:#A5A29A;
775 + margin-top:10px; margin-bottom:16px; line-height:1.6;
776 + transition:all 0.35s cubic-bezier(0.16,1,0.3,1);
777 + }
778 + .hero-sub.hero-collapsed { opacity:0; height:0; overflow:hidden; margin:0; }
779 +
780 + .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; }
781 + .hero-sub-link:hover { color:#4A8B6E; border-bottom-color:#4A8B6E; }
782 + .gold { color:var(--gold); }
783 +
784 + /* ── Search bar ── */
785 + .search-wrap { position:relative; border-radius:16px; transition:all 0.35s cubic-bezier(0.16,1,0.3,1); z-index:20; }
786 + .search-bar {
787 + display:flex; align-items:center; background:var(--bg-elevated);
788 + border-radius:16px; padding:8px 20px;
789 + border:1px solid transparent; transition:all 0.3s cubic-bezier(0.16,1,0.3,1);
790 + }
791 + .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); }
792 + .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); }
793 + .search-icon { flex-shrink:0; transition:stroke 0.3s; }
794 + .search-input-wrap { flex:1; position:relative; display:flex; align-items:center; }
795 + .search-input-wrap input {
796 + width:100%; background:transparent; border:none; outline:none;
797 + color:var(--text); font-size:17px; font-weight:500; font-family:var(--sans);
798 + padding:14px 16px 14px 10px; caret-color:var(--gold); letter-spacing:-0.01em;
799 + }
800 + .search-placeholder { position:absolute; left:10px; top:50%; transform:translateY(-50%); display:flex; align-items:center; pointer-events:none; }
801 + .blink-cursor { width:2px; height:20px; background:var(--gold); margin-right:4px; animation:blink 1s step-end infinite; border-radius:1px; flex-shrink:0; }
802 + .blink-cursor.cursor-hidden { opacity:0; animation:none; }
803 + .placeholder-text { font-family:var(--sans); font-size:16px; color:#B8B5AD; }
804 + .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; }
805 + .search-clear:hover { background:rgba(255,255,255,0.15); color:var(--text); }
806 + .search-loading { display:flex; align-items:center; padding-right:8px; }
807 + .loading-dot { width:8px; height:8px; border-radius:50%; background:var(--gold); animation:pulse 1s ease-in-out infinite; }
808 +
809 + /* ─────────────────────────────────────────
810 + FILTROS INLINE — el cambio principal
811 + ───────────────────────────────────────── */
812 + .filters-row {
813 + display:flex; flex-direction:column; gap:8px;
814 + padding:10px 2px 0;
815 + transition:opacity 0.25s ease, max-height 0.35s cubic-bezier(0.16,1,0.3,1);
816 + max-height:120px; overflow:hidden;
817 + }
818 + .filters-hidden { opacity:0; max-height:0; pointer-events:none; }
819 +
820 + .filter-group {
821 + display:flex; align-items:center; gap:6px; flex-wrap:wrap;
822 + justify-content:center;
823 + }
824 + .filter-group-classifiers { padding-left:0; }
825 +
826 + .filter-divider {
827 + width:1px; height:14px; background:rgba(255,255,255,0.12);
828 + margin:0 4px; flex-shrink:0;
829 + }
830 +
831 + .filter-chip {
832 + font-family:var(--sans); font-size:12px; font-weight:500;
833 + padding:5px 12px; border-radius:20px; border:1px solid rgba(255,255,255,0.1);
834 + background:rgba(255,255,255,0.04); color:var(--text-muted);
835 + cursor:pointer; transition:all 0.18s; white-space:nowrap;
836 + display:flex; align-items:center; gap:5px;
837 + }
838 + .filter-chip:hover:not(:disabled) {
839 + background:rgba(255,255,255,0.08); color:var(--text);
840 + border-color:rgba(255,255,255,0.18);
841 + }
842 + .filter-chip-active {
843 + background:rgba(212,178,84,0.12); color:var(--gold);
844 + border-color:rgba(212,178,84,0.35);
845 + }
846 + .filter-chip-active:hover { background:rgba(212,178,84,0.18); }
847 +
848 + .filter-chip-mode { font-weight:600; font-size:12.5px; }
849 +
850 + .filter-chip-disabled {
851 + opacity:0.4; cursor:default; pointer-events:none;
852 + }
853 +
854 + .chip-soon {
855 + font-family:var(--mono); font-size:9px; letter-spacing:0.04em;
856 + color:var(--text-dim); background:rgba(255,255,255,0.06);
857 + padding:1px 5px; border-radius:4px;
858 + }
859 +
860 + /* ─────────────────────────────────────────
861 + HINT DIARIO
862 + ───────────────────────────────────────── */
863 + .daily-hint {
864 + display:inline-flex; align-items:center; gap:8px;
865 + padding:8px 16px; border-radius:20px;
866 + border:1px solid rgba(74,139,110,0.25);
867 + background:rgba(74,139,110,0.07);
868 + color:var(--text-muted); text-decoration:none;
869 + font-size:13px; transition:all 0.2s;
870 + opacity:0; pointer-events:none;
871 + transform:translateY(4px);
872 + }
873 + .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; }
874 + .daily-hint:hover { background:rgba(74,139,110,0.12); border-color:rgba(74,139,110,0.4); color:var(--text); }
875 + .daily-hint strong { color:#6BBF97; font-weight:600; }
876 +
877 + .daily-dot {
878 + width:7px; height:7px; border-radius:50%;
879 + background:#4A8B6E; flex-shrink:0;
880 + box-shadow:0 0 0 3px rgba(74,139,110,0.2);
881 + animation:pulse 2s ease-in-out infinite;
882 + }
883 +
884 + /* ── Results area ── */
885 + .results-area { margin-top:16px; min-height:240px; position:relative; }
886 + .area-spacer { position:absolute; bottom:0; left:50%; transform:translateX(-50%); height:80px; pointer-events:none; transition:opacity 0.4s; }
887 + .area-spacer-hidden { opacity:0; }
888 +
889 + /* ── Examples ── */
890 + .examples {
891 + display:flex; align-items:center; justify-content:center;
892 + gap:10px; flex-wrap:wrap; padding:12px 0 16px;
893 + transition:opacity 0.25s ease;
894 + }
895 + .examples-label { font-size:13px; color:var(--text-dim); }
896 + .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; }
897 + .example-chip:hover { transform:translateY(-1px); filter:brightness(1.1); }
898 + .examples-hidden { opacity:0; pointer-events:none; }
899 +
900 + /* ── Results ── */
901 + .results-content { position:absolute; top:-16px; left:0; right:0; z-index:10; text-align:left; }
902 + .results-content-filled {
903 + background:var(--bg-surface); border-radius:0 0 16px 16px;
904 + border:1px solid rgba(255,255,255,0.08); border-top:none;
905 + box-shadow:0 12px 40px rgba(0,0,0,0.25); max-height:450px; overflow-y:auto;
906 + }
907 + .results-loading, .results-error, .results-empty, .results-hint {
908 + padding:16px 24px; text-align:center; color:var(--text-muted); font-size:14px; line-height:1.5;
909 + }
910 + .results-error { color:#F28B82; }
911 + .results-empty { color:var(--text-dim); }
912 +
913 + /* Summary */
914 + .results-summary { padding:12px 16px; border-bottom:1px solid rgba(255,255,255,0.06); }
915 + .summary-row { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
916 + .summary-left { flex:1; min-width:0; }
917 + .summary-header { display:flex; align-items:baseline; gap:6px; margin-bottom:4px; flex-wrap:wrap; }
918 + .summary-count { font-weight:700; color:var(--text); font-size:20px; font-family:var(--mono); letter-spacing:-0.02em; }
919 + .summary-header-text { color:var(--text-secondary); font-size:15px; }
920 + .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; }
921 + .summary-meta { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
922 + .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; }
923 + .summary-tag-gold { color:var(--gold); background:rgba(212,178,84,0.1); }
924 + .summary-tag-entities { color:#7EB8D8; background:rgba(126,184,216,0.1); }
925 + .summary-tag-subarea { color:#A8C4A0; background:rgba(168,196,160,0.1); }
926 + .summary-period { color:var(--text-dim); font-weight:300; }
927 + .summary-arrow { color:var(--text-dim); font-size:12px; }
928 +
929 + /* Sort */
930 + .sort-buttons { display:flex; gap:6px; flex-shrink:0; align-items:flex-start; padding-top:2px; }
931 + .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; }
932 + .sort-btn:hover { background:rgba(255,255,255,0.09); color:var(--text); }
933 + .sort-btn-active { background:rgba(212,178,84,0.1); border-color:rgba(212,178,84,0.3); color:var(--gold); }
934 +
935 + /* Results list */
936 + .results-list { padding:4px 0; }
937 + .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); }
938 + @keyframes fadeSlideIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
939 + .result-card:hover { background:rgba(255,255,255,0.04); border-left-color:var(--gold); transform:translateX(4px); }
940 + .result-link-icon { flex-shrink:0; opacity:0; color:var(--gold); transition:opacity 0.2s; }
941 + .result-card:hover .result-link-icon { opacity:1; }
942 + .result-content { flex:1; min-width:0; }
943 + .result-text { font-size:14px; color:var(--text); line-height:1.4; margin-bottom:4px; }
944 + .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; }
945 + .result-meta { display:flex; flex-wrap:wrap; gap:10px; font-family:var(--mono); font-size:12px; }
946 + .result-year { color:var(--text-dim); }
947 + .result-monto { color:var(--gold); font-weight:500; }
948 + .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; }
949 + .result-parent { color:var(--text-muted); font-size:11px; }
950 + .result-subarea { color:#7EB8D8; font-size:11px; }
951 + .result-class-type { color:var(--text-dim); font-size:10px; text-transform:uppercase; letter-spacing:0.05em; }
952 +
953 + /* Load more */
954 + .load-more-container { padding:8px 16px 16px; border-top:1px solid rgba(255,255,255,0.04); margin-top:4px; }
955 + .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; }
956 + .load-more-btn:hover:not(:disabled) { background:rgba(255,255,255,0.08); border-color:rgba(255,255,255,0.12); color:var(--text); }
957 + .load-more-btn:disabled { cursor:wait; opacity:0.7; }
958 + .load-more-btn .loading-dot { width:6px; height:6px; }
959 +
960 + /* Scroll hint */
961 + .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; }
962 + .scroll-hint-hidden { opacity:0; pointer-events:none; }
963 + .scroll-hint span { font-family:var(--mono); font-size:10px; letter-spacing:0.2em; text-transform:uppercase; opacity:0.7; }
964 + .scroll-icon-mouse { display:block; }
965 + .scroll-icon-touch { display:none; }
966 + @keyframes scrollHint { 0%,100% { transform:translateY(0); opacity:0.6; } 50% { transform:translateY(5px); opacity:1; } }
967 +
968 + /* ── Mobile ── */
969 + @media (max-width: 768px) {
970 + .page { height:100vh; height:100dvh; }
971 + .section-dots { display:none; }
972 + .nav-logo { top:16px; left:20px; opacity:1; }
973 + .nav-logo img { height:60px; }
974 + .nav-menu-btn { top:16px; right:16px; }
975 + main { padding:0 20px; justify-content:center; min-height:100%; }
976 + .search-container { padding-top:80px; }
977 + .hero-title { margin-top:0; font-size:clamp(28px,8vw,40px); }
978 + .hero-sub { font-size:14px; margin-bottom:12px; }
979 + .search-bar { padding:6px 12px; }
980 + .search-input-wrap input { font-size:14px; padding:10px 10px 10px 6px; }
981 + .search-placeholder { left:6px; }
982 + .placeholder-text { font-size:13px; }
983 + .search-icon { width:18px; height:18px; }
984 + .filters-row { gap:6px; padding-top:8px; }
985 + .filter-chip { font-size:11px; padding:4px 10px; }
986 + .filter-chip-mode { font-size:11.5px; }
987 + .examples { gap:8px; }
988 + .example-chip { font-size:12px; padding:6px 12px; }
989 + .daily-hint { font-size:12px; padding:7px 12px; }
990 + .results-area { min-height:200px; }
991 + .results-content-filled { max-height:45vh; }
992 + .result-card { padding:8px 14px; }
993 + .result-card:hover { transform:none; }
994 + .result-text { font-size:14px; line-height:1.45; }
995 + .result-meta { font-size:12px; gap:10px; }
996 + .summary-count { font-size:16px; }
997 + .summary-meta { font-size:12px; }
998 + .scroll-icon-mouse { display:none; }
999 + .scroll-icon-touch { display:block; }
1000 + .scroll-hint { position:fixed; bottom:24px; }
1001 + }
1002 +
1003 + @media (max-width: 400px) {
1004 + .nav-logo img { height:50px; }
1005 + main { padding:0 16px; }
1006 + .search-container { padding-top:70px; }
1007 + .search-bar { padding:5px 10px; }
1008 + .search-input-wrap input { font-size:13px; padding:8px 8px 8px 4px; }
1009 + .placeholder-text { font-size:12px; }
1010 + .hero-title { font-size:26px; }
1011 + .hero-sub { font-size:13px; margin-bottom:10px; }
1012 + .results-area { min-height:180px; }
1013 + .results-content-filled { max-height:40vh; }
1014 + .scroll-hint { display:none; }
1015 + .examples { flex-direction:column; gap:6px; }
1016 + .filter-group { gap:5px; }
1017 + .filter-divider { display:none; }
1018 + }
1019 +</style>
1 +<script>
2 + import { page } from '$app/stores';
3 + import { goto } from '$app/navigation';
4 + import { supabase } from '$lib/supabase';
5 + import { onMount } from 'svelte';
6 + import * as d3 from 'd3';
7 +
8 + // Params y query
9 + let codigo = $derived($page.params.codigo);
10 + let gestion = $derived($page.url.searchParams.get('gestion') || new Date().getFullYear().toString());
11 +
12 + // Estado
13 + let loading = $state(true);
14 + let objetoInfo = $state(null); // Info del objeto (desc_objeto, nivel)
15 + let entidadesData = $state([]);
16 + let availableYears = $state([]);
17 + let selectedYear = $state(null);
18 +
19 + // Treemap
20 + let treemapContainer = $state(null);
21 + let treemapWidth = $state(0);
22 + let treemapHeight = $state(500);
23 + let treemapNodes = $state([]);
24 + let treemapRoot = $state(null);
25 + let hoveredNode = $state(null);
26 + let totalDevengado = $state(0);
27 +
28 + // Población para per cápita
29 + const POBLACION_BOLIVIA = 12006031;
30 +
31 + // Formatters
32 + function formatMoney(value) {
33 + if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
34 + if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
35 + if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
36 + return `Bs ${value.toFixed(0)}`;
37 + }
38 +
39 + function formatMoneyCompact(value) {
40 + if (value >= 1e9) return `${(value / 1e9).toFixed(1)}MM`;
41 + if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
42 + if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`;
43 + return value.toFixed(0);
44 + }
45 +
46 + function formatPerCapita(value) {
47 + const perCapita = value / POBLACION_BOLIVIA;
48 + return `Bs ${perCapita.toFixed(0)}/hab`;
49 + }
50 +
51 + // Colores para entidades (gradiente basado en ranking)
52 + function getEntityColor(index, total, opacity = 0.85) {
53 + // Gradiente de más oscuro (top) a más claro (bottom)
54 + const hue = 210; // Azul
55 + const saturation = 60 + (index / total) * 20;
56 + const lightness = 35 + (index / total) * 30;
57 + return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`;
58 + }
59 +
60 + function getTextColor(index, total) {
61 + return index < total * 0.6 ? '#ffffff' : '#1a1a1a';
62 + }
63 +
64 + // Cargar años disponibles
65 + async function loadAvailableYears() {
66 + const { data, error } = await supabase
67 + .schema('ppto')
68 + .from('treemap_objeto')
69 + .select('gestion')
70 + .eq('objeto', parseInt(codigo))
71 + .gt('devengado', 0);
72 +
73 + if (!error && data) {
74 + const years = [...new Set(data.map(d => d.gestion))].sort((a, b) => b - a);
75 + availableYears = years;
76 +
77 + // Usar año de query param o el más reciente
78 + const yearParam = parseInt(gestion);
79 + selectedYear = years.includes(yearParam) ? yearParam : years[0];
80 + }
81 + }
82 +
83 + // Cargar datos de entidades para el objeto
84 + async function loadEntityData() {
85 + if (!selectedYear || !codigo) return;
86 +
87 + loading = true;
88 +
89 + try {
90 + // Obtener info del objeto
91 + const { data: infoData } = await supabase
92 + .schema('ppto')
93 + .from('treemap_objeto')
94 + .select('desc_objeto, nivel')
95 + .eq('objeto', parseInt(codigo))
96 + .eq('gestion', selectedYear)
97 + .limit(1)
98 + .single();
99 +
100 + if (infoData) {
101 + objetoInfo = infoData;
102 + }
103 +
104 + // Obtener distribución por entidades (excluyendo entidad 0=total y 99=otros)
105 + const { data, error } = await supabase
106 + .schema('ppto')
107 + .from('treemap_objeto')
108 + .select('entidad, desc_entidad, devengado')
109 + .eq('objeto', parseInt(codigo))
110 + .eq('gestion', selectedYear)
111 + .neq('entidad', 0)
112 + .neq('entidad', 99)
113 + .gt('devengado', 0)
114 + .order('devengado', { ascending: false });
115 +
116 + if (error) {
117 + console.error('Error loading entity data:', error);
118 + return;
119 + }
120 +
121 + entidadesData = data || [];
122 + totalDevengado = entidadesData.reduce((sum, d) => sum + (d.devengado || 0), 0);
123 +
124 + buildTreemap();
125 + } catch (err) {
126 + console.error('Error:', err);
127 + } finally {
128 + loading = false;
129 + }
130 + }
131 +
132 + // Construir treemap
133 + function buildTreemap() {
134 + if (!entidadesData.length || treemapWidth <= 0) return;
135 +
136 + const hierarchy = d3.hierarchy({
137 + name: 'root',
138 + children: entidadesData.map((d, i) => ({
139 + id: d.entidad,
140 + name: d.desc_entidad || `Entidad ${d.entidad}`,
141 + value: d.devengado,
142 + index: i
143 + }))
144 + }).sum(d => d.value || 0)
145 + .sort((a, b) => b.value - a.value);
146 +
147 + const treemap = d3.treemap()
148 + .size([treemapWidth, treemapHeight])
149 + .padding(2)
150 + .round(true);
151 +
152 + treemap(hierarchy);
153 + treemapRoot = hierarchy;
154 + treemapNodes = hierarchy.children || [];
155 + }
156 +
157 + // Resize observer
158 + function setupResizeObserver() {
159 + if (!treemapContainer) return;
160 +
161 + const observer = new ResizeObserver(entries => {
162 + for (const entry of entries) {
163 + treemapWidth = entry.contentRect.width;
164 + buildTreemap();
165 + }
166 + });
167 +
168 + observer.observe(treemapContainer);
169 + return () => observer.disconnect();
170 + }
171 +
172 + // Navegación
173 + function goBack() {
174 + goto(`/clasificadores/objeto-gasto?modo=mapa`);
175 + }
176 +
177 + function changeYear(year) {
178 + selectedYear = year;
179 + goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
180 + }
181 +
182 + // Efectos
183 + $effect(() => {
184 + if (codigo) {
185 + loadAvailableYears();
186 + }
187 + });
188 +
189 + $effect(() => {
190 + if (selectedYear && codigo) {
191 + loadEntityData();
192 + }
193 + });
194 +
195 + $effect(() => {
196 + if (treemapContainer) {
197 + return setupResizeObserver();
198 + }
199 + });
200 +
201 + $effect(() => {
202 + if (entidadesData.length && treemapWidth > 0) {
203 + buildTreemap();
204 + }
205 + });
206 +
207 + onMount(() => {
208 + if (treemapContainer) {
209 + treemapWidth = treemapContainer.clientWidth;
210 + }
211 + });
212 +</script>
213 +
214 +<svelte:head>
215 + <title>{objetoInfo?.desc_objeto || `Objeto ${codigo}`} - Distribución por Entidades</title>
216 +</svelte:head>
217 +
218 +<div class="page-container">
219 + <!-- Header -->
220 + <header class="page-header">
221 + <button class="back-btn" onclick={goBack}>
222 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
223 + <path d="M19 12H5M12 19l-7-7 7-7"/>
224 + </svg>
225 + Volver al mapa
226 + </button>
227 +
228 + <div class="header-content">
229 + <div class="header-badge">{objetoInfo?.nivel || 'objeto'}</div>
230 + <h1 class="header-title">
231 + <span class="header-code">{codigo}</span>
232 + <span class="header-separator">·</span>
233 + <span class="header-name">{objetoInfo?.desc_objeto || 'Cargando...'}</span>
234 + </h1>
235 + <p class="header-subtitle">¿Qué entidades gastan más en esto?</p>
236 + </div>
237 +
238 + <!-- Selector de año -->
239 + <div class="year-selector">
240 + <label>Gestión:</label>
241 + <select bind:value={selectedYear} onchange={(e) => changeYear(parseInt(e.target.value))}>
242 + {#each availableYears as year}
243 + <option value={year}>{year}</option>
244 + {/each}
245 + </select>
246 + </div>
247 + </header>
248 +
249 + <!-- Stats resumen -->
250 + {#if !loading && entidadesData.length > 0}
251 + <div class="stats-bar">
252 + <div class="stat">
253 + <span class="stat-value">{entidadesData.length}</span>
254 + <span class="stat-label">entidades</span>
255 + </div>
256 + <div class="stat">
257 + <span class="stat-value">{formatMoney(totalDevengado)}</span>
258 + <span class="stat-label">total ejecutado</span>
259 + </div>
260 + <div class="stat">
261 + <span class="stat-value">{formatPerCapita(totalDevengado)}</span>
262 + <span class="stat-label">per cápita</span>
263 + </div>
264 + </div>
265 + {/if}
266 +
267 + <!-- Treemap -->
268 + <div class="treemap-section">
269 + <div
270 + bind:this={treemapContainer}
271 + class="treemap-container"
272 + style="height: {treemapHeight}px;"
273 + >
274 + {#if loading}
275 + <div class="loading-state">
276 + <div class="loader"></div>
277 + <p>Cargando distribución...</p>
278 + </div>
279 + {:else if treemapNodes.length > 0}
280 + <svg width={treemapWidth} height={treemapHeight} class="block">
281 + {#each treemapNodes as node, i}
282 + {@const width = node.x1 - node.x0}
283 + {@const height = node.y1 - node.y0}
284 + {@const isHovered = hoveredNode === node}
285 + {@const pct = (node.value / totalDevengado) * 100}
286 + {@const area = width * height}
287 + {@const scale = Math.sqrt(area) / 12}
288 + {@const pctSize = Math.min(22, Math.max(10, scale * 1.6))}
289 + {@const nameSize = Math.min(14, Math.max(8, scale * 1.0))}
290 + {@const valueSize = Math.min(12, Math.max(7, scale * 0.8))}
291 + {@const textColor = getTextColor(i, treemapNodes.length)}
292 +
293 + <g
294 + class="treemap-node"
295 + transform="translate({node.x0}, {node.y0})"
296 + onmouseenter={() => hoveredNode = node}
297 + onmouseleave={() => hoveredNode = null}
298 + >
299 + <rect
300 + width={width}
301 + height={height}
302 + fill={getEntityColor(i, treemapNodes.length, isHovered ? 1 : 0.85)}
303 + stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
304 + stroke-width={isHovered ? 2 : 0}
305 + rx="3"
306 + />
307 +
308 + {#if width > 40 && height > 25}
309 + <foreignObject x="4" y="4" width={width - 8} height={height - 8}>
310 + <div class="node-content" style="color: {textColor};">
311 + <div class="node-pct" style="font-size: {pctSize}px;">
312 + {pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
313 + </div>
314 + {#if height > 45 && width > 60}
315 + <div class="node-name" style="font-size: {nameSize}px;">
316 + {width > 120 ? node.data.name : node.data.name.slice(0, Math.floor(width / 7)) + (node.data.name.length > Math.floor(width / 7) ? '…' : '')}
317 + </div>
318 + {/if}
319 + {#if height > 65 && width > 70}
320 + <div class="node-value" style="font-size: {valueSize}px;">
321 + {formatMoneyCompact(node.value)}
322 + </div>
323 + {/if}
324 + </div>
325 + </foreignObject>
326 + {/if}
327 + </g>
328 + {/each}
329 + </svg>
330 +
331 + <!-- Tooltip -->
332 + {#if hoveredNode}
333 + {@const pct = (hoveredNode.value / totalDevengado) * 100}
334 + <div
335 + class="treemap-tooltip"
336 + style="
337 + left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 280)}px;
338 + top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
339 + "
340 + >
341 + <div class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</div>
342 + <div class="tooltip-name">{hoveredNode.data.name}</div>
343 + <div class="tooltip-value">{formatMoney(hoveredNode.value)}</div>
344 + <div class="tooltip-pct">{pct.toFixed(1)}% del total · {formatPerCapita(hoveredNode.value)}</div>
345 + </div>
346 + {/if}
347 + {:else}
348 + <div class="empty-state">
349 + <p>No hay datos de entidades para este objeto en {selectedYear}</p>
350 + </div>
351 + {/if}
352 + </div>
353 + </div>
354 +
355 + <!-- Ranking list -->
356 + {#if !loading && entidadesData.length > 0}
357 + <div class="ranking-section">
358 + <h2 class="ranking-title">Ranking de entidades</h2>
359 + <div class="ranking-list">
360 + {#each entidadesData.slice(0, 20) as entity, i}
361 + {@const pct = (entity.devengado / totalDevengado) * 100}
362 + <div class="ranking-item">
363 + <span class="ranking-pos">#{i + 1}</span>
364 + <div class="ranking-bar-container">
365 + <div class="ranking-bar" style="width: {pct}%; background: {getEntityColor(i, entidadesData.length)};"></div>
366 + <div class="ranking-info">
367 + <span class="ranking-name">{entity.desc_entidad || `Entidad ${entity.entidad}`}</span>
368 + <span class="ranking-value">{formatMoney(entity.devengado)} <span class="ranking-pct">({pct.toFixed(1)}%)</span></span>
369 + </div>
370 + </div>
371 + </div>
372 + {/each}
373 + {#if entidadesData.length > 20}
374 + <p class="ranking-more">+ {entidadesData.length - 20} entidades más</p>
375 + {/if}
376 + </div>
377 + </div>
378 + {/if}
379 +</div>
380 +
381 +<style>
382 + .page-container {
383 + min-height: 100vh;
384 + background: var(--theme-fondo);
385 + padding: 1.5rem;
386 + max-width: 1400px;
387 + margin: 0 auto;
388 + }
389 +
390 + /* Header */
391 + .page-header {
392 + margin-bottom: 1.5rem;
393 + }
394 +
395 + .back-btn {
396 + display: inline-flex;
397 + align-items: center;
398 + gap: 0.5rem;
399 + padding: 0.5rem 1rem;
400 + background: var(--theme-surface);
401 + border: 1px solid var(--theme-borde);
402 + border-radius: 8px;
403 + color: var(--theme-texto);
404 + font-size: 0.875rem;
405 + cursor: pointer;
406 + transition: all 0.15s ease;
407 + margin-bottom: 1rem;
408 + }
409 + .back-btn:hover {
410 + background: var(--theme-fill);
411 + border-color: var(--theme-accent);
412 + color: var(--theme-accent);
413 + }
414 +
415 + .header-content {
416 + margin-bottom: 1rem;
417 + }
418 +
419 + .header-badge {
420 + display: inline-block;
421 + padding: 0.25rem 0.75rem;
422 + background: var(--theme-accent);
423 + color: white;
424 + font-size: 0.75rem;
425 + font-weight: 600;
426 + text-transform: uppercase;
427 + letter-spacing: 0.5px;
428 + border-radius: 4px;
429 + margin-bottom: 0.5rem;
430 + }
431 +
432 + .header-title {
433 + font-size: 1.5rem;
434 + font-weight: 600;
435 + color: var(--theme-titulo);
436 + margin: 0 0 0.25rem 0;
437 + line-height: 1.3;
438 + }
439 +
440 + .header-code {
441 + font-family: var(--font-mono);
442 + opacity: 0.7;
443 + }
444 + .header-separator {
445 + opacity: 0.4;
446 + margin: 0 0.25rem;
447 + }
448 +
449 + .header-subtitle {
450 + font-size: 1rem;
451 + color: var(--theme-texto);
452 + opacity: 0.7;
453 + margin: 0;
454 + }
455 +
456 + .year-selector {
457 + display: flex;
458 + align-items: center;
459 + gap: 0.5rem;
460 + }
461 + .year-selector label {
462 + font-size: 0.875rem;
463 + color: var(--theme-texto);
464 + }
465 + .year-selector select {
466 + padding: 0.5rem 1rem;
467 + background: var(--theme-surface);
468 + border: 1px solid var(--theme-borde);
469 + border-radius: 6px;
470 + color: var(--theme-titulo);
471 + font-size: 0.875rem;
472 + cursor: pointer;
473 + }
474 +
475 + /* Stats bar */
476 + .stats-bar {
477 + display: flex;
478 + gap: 2rem;
479 + padding: 1rem 1.5rem;
480 + background: var(--theme-surface);
481 + border: 1px solid var(--theme-borde);
482 + border-radius: 12px;
483 + margin-bottom: 1.5rem;
484 + }
485 +
486 + .stat {
487 + display: flex;
488 + flex-direction: column;
489 + }
490 + .stat-value {
491 + font-size: 1.25rem;
492 + font-weight: 600;
493 + color: var(--theme-titulo);
494 + font-variant-numeric: tabular-nums;
495 + }
496 + .stat-label {
497 + font-size: 0.75rem;
498 + color: var(--theme-texto);
499 + opacity: 0.7;
500 + text-transform: uppercase;
501 + letter-spacing: 0.5px;
502 + }
503 +
504 + /* Treemap */
505 + .treemap-section {
506 + margin-bottom: 2rem;
507 + }
508 +
509 + .treemap-container {
510 + position: relative;
511 + background: var(--theme-surface);
512 + border: 1px solid var(--theme-borde);
513 + border-radius: 12px;
514 + overflow: hidden;
515 + }
516 +
517 + .loading-state,
518 + .empty-state {
519 + position: absolute;
520 + inset: 0;
521 + display: flex;
522 + flex-direction: column;
523 + align-items: center;
524 + justify-content: center;
525 + color: var(--theme-texto);
526 + opacity: 0.7;
527 + }
528 +
529 + .loader {
530 + width: 40px;
531 + height: 40px;
532 + border: 3px solid var(--theme-borde);
533 + border-top-color: var(--theme-accent);
534 + border-radius: 50%;
535 + animation: spin 1s linear infinite;
536 + margin-bottom: 1rem;
537 + }
538 +
539 + @keyframes spin {
540 + to { transform: rotate(360deg); }
541 + }
542 +
543 + :global(.treemap-node) {
544 + transition: opacity 0.15s ease;
545 + }
546 + :global(.treemap-node rect) {
547 + transition: fill 0.15s ease;
548 + }
549 +
550 + .node-content {
551 + display: flex;
552 + flex-direction: column;
553 + gap: 0;
554 + overflow: hidden;
555 + font-family: var(--font-sans);
556 + }
557 +
558 + .node-pct {
559 + font-weight: 600;
560 + line-height: 1;
561 + opacity: 0.9;
562 + }
563 +
564 + .node-name {
565 + line-height: 1.2;
566 + margin-top: 2px;
567 + opacity: 0.85;
568 + }
569 +
570 + .node-value {
571 + font-variant-numeric: tabular-nums;
572 + opacity: 0.7;
573 + margin-top: 2px;
574 + }
575 +
576 + /* Tooltip */
577 + .treemap-tooltip {
578 + position: absolute;
579 + pointer-events: none;
580 + padding: 0.75rem 1rem;
581 + border-radius: 10px;
582 + z-index: 10;
583 + max-width: 280px;
584 + background: rgba(255, 255, 255, 0.85);
585 + backdrop-filter: blur(12px);
586 + -webkit-backdrop-filter: blur(12px);
587 + border: 1px solid rgba(255, 255, 255, 0.3);
588 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
589 + color: #1a1a1a;
590 + font-family: var(--font-sans);
591 + }
592 +
593 + :global(html.dark) .treemap-tooltip {
594 + background: rgba(30, 30, 30, 0.85);
595 + border: 1px solid rgba(255, 255, 255, 0.1);
596 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
597 + color: #f5f5f5;
598 + }
599 +
600 + .tooltip-rank {
601 + font-size: 0.7rem;
602 + font-weight: 600;
603 + text-transform: uppercase;
604 + letter-spacing: 0.5px;
605 + opacity: 0.6;
606 + margin-bottom: 0.25rem;
607 + }
608 +
609 + .tooltip-name {
610 + font-size: 0.9rem;
611 + font-weight: 500;
612 + line-height: 1.3;
613 + }
614 +
615 + .tooltip-value {
616 + font-size: 0.875rem;
617 + font-weight: 600;
618 + margin-top: 0.5rem;
619 + font-variant-numeric: tabular-nums;
620 + }
621 +
622 + .tooltip-pct {
623 + font-size: 0.75rem;
624 + opacity: 0.7;
625 + font-variant-numeric: tabular-nums;
626 + }
627 +
628 + /* Ranking list */
629 + .ranking-section {
630 + background: var(--theme-surface);
631 + border: 1px solid var(--theme-borde);
632 + border-radius: 12px;
633 + padding: 1.5rem;
634 + }
635 +
636 + .ranking-title {
637 + font-size: 1rem;
638 + font-weight: 600;
639 + color: var(--theme-titulo);
640 + margin: 0 0 1rem 0;
641 + }
642 +
643 + .ranking-list {
644 + display: flex;
645 + flex-direction: column;
646 + gap: 0.75rem;
647 + }
648 +
649 + .ranking-item {
650 + display: grid;
651 + grid-template-columns: 2.5rem 1fr;
652 + gap: 0.75rem;
653 + align-items: center;
654 + }
655 +
656 + .ranking-pos {
657 + font-size: 0.875rem;
658 + font-weight: 600;
659 + color: var(--theme-texto);
660 + opacity: 0.5;
661 + font-variant-numeric: tabular-nums;
662 + }
663 +
664 + .ranking-bar-container {
665 + position: relative;
666 + height: 32px;
667 + background: var(--theme-fill);
668 + border-radius: 4px;
669 + overflow: hidden;
670 + }
671 +
672 + .ranking-bar {
673 + position: absolute;
674 + left: 0;
675 + top: 0;
676 + height: 100%;
677 + border-radius: 4px;
678 + transition: width 0.3s ease;
679 + }
680 +
681 + .ranking-info {
682 + position: absolute;
683 + left: 0.75rem;
684 + right: 0.75rem;
685 + top: 50%;
686 + transform: translateY(-50%);
687 + display: flex;
688 + justify-content: space-between;
689 + align-items: center;
690 + pointer-events: none;
691 + }
692 +
693 + .ranking-name {
694 + font-size: 0.8125rem;
695 + font-weight: 500;
696 + color: var(--theme-titulo);
697 + white-space: nowrap;
698 + overflow: hidden;
699 + text-overflow: ellipsis;
700 + max-width: 60%;
701 + }
702 +
703 + .ranking-value {
704 + font-size: 0.8125rem;
705 + font-weight: 500;
706 + color: var(--theme-titulo);
707 + font-variant-numeric: tabular-nums;
708 + }
709 +
710 + .ranking-pct {
711 + opacity: 0.6;
712 + font-weight: 400;
713 + }
714 +
715 + .ranking-more {
716 + font-size: 0.8125rem;
717 + color: var(--theme-texto);
718 + opacity: 0.6;
719 + text-align: center;
720 + margin: 0.5rem 0 0 0;
721 + }
722 +
723 + /* Responsive */
724 + @media (max-width: 640px) {
725 + .page-container {
726 + padding: 1rem;
727 + }
728 +
729 + .header-title {
730 + font-size: 1.25rem;
731 + }
732 +
733 + .stats-bar {
734 + flex-wrap: wrap;
735 + gap: 1rem;
736 + }
737 +
738 + .stat-value {
739 + font-size: 1.1rem;
740 + }
741 +
742 + .ranking-item {
743 + grid-template-columns: 2rem 1fr;
744 + }
745 +
746 + .ranking-name {
747 + max-width: 50%;
748 + }
749 + }
750 +</style>
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 import * as d3 from 'd3'; 3 import * as d3 from 'd3';
4 + import * as Plot from '@observablehq/plot';
4 5
5 - // Estado 6 + // Entidad de ejemplo
6 - let streamData = $state([]); 7 + const entidad = {
7 - let loading = $state(true); 8 + codigo: 139,
8 - let container = $state(null); 9 + nombre: 'Universidad Mayor de San Andrés',
9 - let width = $state(0); 10 + sigla: 'UMSA',
10 - let height = $state(500); 11 + sector: 'Universidades Públicas'
11 - let tooltip = $state({ show: false, x: 0, y: 0, data: null }); 12 + };
12 -
13 - // Drilldown
14 - let currentLevel = $state('tipo'); // tipo -> clase -> cuenta -> subcuenta
15 - let breadcrumb = $state([{ level: 'tipo', codigo: null, descripcion: 'Todos los ingresos' }]);
16 - let currentParent = $state(null);
17 -
18 - // Colores para las categorías
19 - const colorScale = d3.scaleOrdinal()
20 - .range([
21 - '#4A8B6E', '#C9A751', '#8B6E9B', '#6B9FD4', '#D4846B',
22 - '#5B8FA8', '#A8845B', '#7B9B6B', '#9B7B8B', '#6B8B9B',
23 - '#B89B6B', '#6B7B9B', '#9B8B6B', '#7B8B7B', '#8B7B6B',
24 - '#6B9B8B', '#9B6B7B', '#7B6B9B', '#8B9B6B', '#6B8B7B'
25 - ]);
26 -
27 - // Cargar datos
28 - async function loadData() {
29 - loading = true;
30 - try {
31 - const response = await fetch('/stream1.csv');
32 - const text = await response.text();
33 - const parsed = d3.csvParse(text, d => ({
34 - entidad: +d.entidad,
35 - año: +d.año,
36 - nivel: d.nivel,
37 - codigo: +d.codigo,
38 - descripcion: d.descripcion,
39 - codigo_padre: d.codigo_padre ? +d.codigo_padre : null,
40 - desc_padre: d.desc_padre || null,
41 - devengado: +d.devengado
42 - }));
43 -
44 - // Filtrar solo entidad 0 (Todo el Estado)
45 - streamData = parsed.filter(d => d.entidad === 0);
46 - console.log('Stream data loaded:', streamData.length, 'rows');
47 - } catch (err) {
48 - console.error('Error loading data:', err);
49 - }
50 - loading = false;
51 - }
52 -
53 - // Obtener datos filtrados para el nivel actual
54 - function getFilteredData() {
55 - let filtered = streamData.filter(d => d.nivel === currentLevel);
56 -
57 - if (currentParent !== null) {
58 - filtered = filtered.filter(d => d.codigo_padre === currentParent);
59 - }
60 -
61 - return filtered;
62 - }
63 -
64 - // Preparar datos para el streamgraph
65 - function prepareStreamData() {
66 - const filtered = getFilteredData();
67 - if (!filtered.length) return { years: [], series: [], keys: [] };
68 13
69 - // Agrupar por año 14 + // Estado
70 - const byYear = d3.group(filtered, d => d.año); 15 + let gestionSeleccionada = $state(2024);
71 - const years = Array.from(byYear.keys()).sort((a, b) => a - b); 16 + const gestiones = [2020, 2021, 2022, 2023, 2024, 2025];
72 - 17 +
73 - // Obtener todas las categorías únicas 18 + // Datos mock - Historia temporal (20 años)
74 - const categories = [...new Set(filtered.map(d => d.codigo))]; 19 + const historiaIngresos = Array.from({ length: 20 }, (_, i) => ({
75 - 20 + gestion: 2005 + i,
76 - // Crear estructura para stack 21 + monto: 800000000 + Math.random() * 400000000 + i * 50000000
77 - const stackData = years.map(year => { 22 + }));
78 - const yearData = byYear.get(year) || []; 23 +
79 - const row = { año: year }; 24 + const historiaGastos = Array.from({ length: 20 }, (_, i) => ({
80 - categories.forEach(cat => { 25 + gestion: 2005 + i,
81 - const item = yearData.find(d => d.codigo === cat); 26 + monto: 750000000 + Math.random() * 350000000 + i * 45000000
82 - row[cat] = item ? item.devengado : 0; 27 + }));
83 - }); 28 +
84 - return row; 29 + // Datos mock - Composición del gasto
85 - }); 30 + const gastoObjetos = [
86 - 31 + { codigo: '10000', nombre: 'Servicios Personales', monto: 850000000, padre: 'Gasto Corriente' },
87 - // Crear el stack 32 + { codigo: '20000', nombre: 'Servicios No Personales', monto: 180000000, padre: 'Gasto Corriente' },
88 - const stack = d3.stack() 33 + { codigo: '30000', nombre: 'Materiales y Suministros', monto: 120000000, padre: 'Gasto Corriente' },
89 - .keys(categories) 34 + { codigo: '40000', nombre: 'Activos Reales', monto: 95000000, padre: 'Inversión' },
90 - .offset(d3.stackOffsetWiggle) 35 + { codigo: '70000', nombre: 'Transferencias', monto: 45000000, padre: 'Transferencias' },
91 - .order(d3.stackOrderInsideOut); 36 + { codigo: '80000', nombre: 'Impuestos y Otros', monto: 25000000, padre: 'Otros' }
92 - 37 + ];
93 - const series = stack(stackData); 38 +
39 + const gastoFinalidad = [
40 + { codigo: '22', nombre: 'Educación Superior', monto: 920000000, padre: 'Educación' },
41 + { codigo: '23', nombre: 'Investigación', monto: 180000000, padre: 'Educación' },
42 + { codigo: '14', nombre: 'Administración', monto: 150000000, padre: 'Servicios Generales' },
43 + { codigo: '31', nombre: 'Salud', monto: 45000000, padre: 'Servicios Sociales' },
44 + { codigo: '42', nombre: 'Extensión', monto: 20000000, padre: 'Cultura' }
45 + ];
46 +
47 + const gastoSectores = [
48 + { codigo: 'EDU', nombre: 'Educación', monto: 1050000000, padre: 'Social' },
49 + { codigo: 'ADM', nombre: 'Administración Pública', monto: 180000000, padre: 'Gubernamental' },
50 + { codigo: 'SAL', nombre: 'Salud', monto: 55000000, padre: 'Social' },
51 + { codigo: 'CUL', nombre: 'Cultura y Deporte', monto: 30000000, padre: 'Social' }
52 + ];
53 +
54 + // Datos mock - Origen del dinero
55 + const ingresoRubros = [
56 + { codigo: '1200', nombre: 'Transferencias TGN', monto: 650000000, padre: 'Transferencias' },
57 + { codigo: '1400', nombre: 'Venta de Servicios', monto: 280000000, padre: 'Ingresos Propios' },
58 + { codigo: '1100', nombre: 'Matrículas y Aranceles', monto: 150000000, padre: 'Ingresos Propios' },
59 + { codigo: '1300', nombre: 'Regalías e IDH', monto: 95000000, padre: 'Coparticipación' },
60 + { codigo: '1900', nombre: 'Otros Ingresos', monto: 40000000, padre: 'Otros' }
61 + ];
62 +
63 + const ingresoOrganismos = [
64 + { codigo: 'TGN', nombre: 'Tesoro General de la Nación', monto: 720000000, padre: 'Gobierno Central' },
65 + { codigo: 'PROP', nombre: 'Recursos Propios', monto: 350000000, padre: 'Autogestión' },
66 + { codigo: 'IDH', nombre: 'Impuesto Directo a Hidrocarburos', monto: 95000000, padre: 'Coparticipación' },
67 + { codigo: 'COOP', nombre: 'Cooperación Internacional', monto: 50000000, padre: 'Externo' }
68 + ];
69 +
70 + // Ranking mock
71 + const ranking = {
72 + gastos: { posicion: 23, total: 647 },
73 + ingresos: { posicion: 28, total: 647 }
74 + };
75 +
76 + // Contenedores para barras D3
77 + let barContainers = $state({});
78 + let hoverData = $state({});
79 +
80 + // Contenedores para gráficos de historia
81 + let chartIngresosContainer = $state(null);
82 + let chartGastosContainer = $state(null);
83 +
84 + // Clasificadores de gasto
85 + const clasificadoresGasto = [
86 + { key: 'objetos', label: 'Objetos de gasto', data: gastoObjetos },
87 + { key: 'finalidad', label: 'Finalidad y función', data: gastoFinalidad },
88 + { key: 'sectores', label: 'Sectores económicos', data: gastoSectores }
89 + ];
90 +
91 + // Clasificadores de ingreso
92 + const clasificadoresIngreso = [
93 + { key: 'rubros', label: 'Rubros de ingreso', data: ingresoRubros },
94 + { key: 'organismos', label: 'Organismos financiadores', data: ingresoOrganismos }
95 + ];
96 +
97 + // Totales
98 + function getTotal(data) {
99 + return data.reduce((sum, d) => sum + d.monto, 0);
100 + }
101 +
102 + // Formatear montos
103 + function formatearMonto(valor) {
104 + if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)} mil millones`;
105 + if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)} millones`;
106 + if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)} mil`;
107 + return valor.toFixed(0);
108 + }
109 +
110 + function formatearMontoCorto(valor) {
111 + if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
112 + if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
113 + if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)}K`;
114 + return valor.toFixed(0);
115 + }
116 +
117 + // Colores del tema
118 + function getChartColors() {
119 + const root = document.documentElement;
120 + const isDark = root.classList.contains('dark');
121 + return {
122 + barDefault: isDark ? '#3a3a38' : '#e0e0e0',
123 + barHighlight: isDark ? '#C9A751' : '#F3EAD8',
124 + barStroke: isDark ? '#5a5a55' : '#b0b0b0',
125 + barStrokeDefault: isDark ? '#2a2a28' : 'white'
126 + };
127 + }
128 +
129 + // Renderizar barras D3
130 + function renderizarBarras(key, data) {
131 + const container = barContainers[key];
132 + if (!container) return;
94 133
95 - // Agregar descripciones a las series 134 + const total = getTotal(data);
96 - series.forEach(s => { 135 + const height = 48;
97 - const item = filtered.find(d => d.codigo === +s.key); 136 + const radius = 4;
98 - s.descripcion = item ? item.descripcion : s.key; 137 +
138 + d3.select(container).selectAll('*').remove();
139 +
140 + const containerRect = container.getBoundingClientRect();
141 + const width = containerRect.width || 800;
142 +
143 + const svg = d3.select(container)
144 + .append('svg')
145 + .attr('width', '100%')
146 + .attr('height', height)
147 + .attr('viewBox', `0 0 ${width} ${height}`)
148 + .attr('preserveAspectRatio', 'none')
149 + .style('display', 'block');
150 +
151 + let cumulative = 0;
152 + const segments = data.map(d => {
153 + const segWidth = (d.monto / total) * width;
154 + const segment = { ...d, x: cumulative, width: segWidth };
155 + cumulative += segWidth;
156 + return segment;
99 }); 157 });
100 158
101 - return { years, series, keys: categories, stackData }; 159 + const chartColors = getChartColors();
102 - } 160 + const colorDefault = chartColors.barDefault;
103 - 161 + const colorHover = chartColors.barHighlight;
104 - // Formatear moneda 162 + const colorFirst = chartColors.barHighlight;
105 - function formatMoney(value) { 163 +
106 - if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}B`; 164 + const bars = svg.selectAll('rect')
107 - if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`; 165 + .data(segments)
108 - if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`; 166 + .enter()
109 - return `Bs ${value.toFixed(0)}`; 167 + .append('rect')
168 + .attr('x', d => d.x)
169 + .attr('y', 2)
170 + .attr('width', d => Math.max(d.width - 1, 1))
171 + .attr('height', height - 4)
172 + .attr('rx', (d, i) => {
173 + if (i === 0) return radius;
174 + if (i === segments.length - 1) return radius;
175 + return 0;
176 + })
177 + .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
178 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
179 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
180 + .style('cursor', 'pointer')
181 + .on('mouseenter', function(event, d) {
182 + bars
183 + .attr('fill', colorDefault)
184 + .attr('stroke', chartColors.barStrokeDefault)
185 + .attr('stroke-width', 0.5);
186 + d3.select(this)
187 + .attr('fill', colorHover)
188 + .attr('stroke', chartColors.barStroke)
189 + .attr('stroke-width', 1);
190 + hoverData[key] = d;
191 + hoverData = { ...hoverData };
192 + })
193 + .on('mouseleave', function() {
194 + bars
195 + .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
196 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
197 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
198 + hoverData[key] = null;
199 + hoverData = { ...hoverData };
200 + });
110 } 201 }
111 202
112 - // Manejar clic para drilldown 203 + // Item a mostrar (hover o primero)
113 - function handleClick(codigo, descripcion) { 204 + function getDisplayItem(key, data) {
114 - const levels = ['tipo', 'clase', 'cuenta', 'subcuenta']; 205 + const hover = hoverData[key];
115 - const currentIndex = levels.indexOf(currentLevel); 206 + if (hover) return hover;
116 - 207 + return data[0] || null;
117 - if (currentIndex < levels.length - 1) { 208 + }
118 - const nextLevel = levels[currentIndex + 1]; 209 +
119 - 210 + // Renderizar gráficos de historia
120 - // Verificar si hay datos en el siguiente nivel 211 + function renderizarHistoria() {
121 - const hasChildren = streamData.some( 212 + if (chartIngresosContainer) {
122 - d => d.nivel === nextLevel && d.codigo_padre === codigo 213 + chartIngresosContainer.innerHTML = '';
123 - ); 214 + const plot = Plot.plot({
124 - 215 + width: chartIngresosContainer.clientWidth,
125 - if (hasChildren) { 216 + height: 180,
126 - currentParent = codigo; 217 + marginLeft: 50,
127 - currentLevel = nextLevel; 218 + marginRight: 20,
128 - breadcrumb = [...breadcrumb, { level: nextLevel, codigo, descripcion }]; 219 + marginTop: 20,
129 - } 220 + marginBottom: 30,
221 + style: { background: 'transparent', fontFamily: 'var(--font-sans)' },
222 + x: { label: null, tickFormat: d => d },
223 + y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
224 + marks: [
225 + Plot.areaY(historiaIngresos, { x: 'gestion', y: 'monto', fill: '#4A8B6E', fillOpacity: 0.3 }),
226 + Plot.lineY(historiaIngresos, { x: 'gestion', y: 'monto', stroke: '#4A8B6E', strokeWidth: 2 }),
227 + Plot.dot(historiaIngresos, { x: 'gestion', y: 'monto', fill: '#4A8B6E', r: 3 })
228 + ]
229 + });
230 + chartIngresosContainer.appendChild(plot);
130 } 231 }
131 - }
132 232
133 - // Navegar con breadcrumb 233 + if (chartGastosContainer) {
134 - function navigateTo(index) { 234 + chartGastosContainer.innerHTML = '';
135 - if (index === 0) { 235 + const plot = Plot.plot({
136 - currentLevel = 'tipo'; 236 + width: chartGastosContainer.clientWidth,
137 - currentParent = null; 237 + height: 180,
138 - breadcrumb = [{ level: 'tipo', codigo: null, descripcion: 'Todos los ingresos' }]; 238 + marginLeft: 50,
139 - } else { 239 + marginRight: 20,
140 - const target = breadcrumb[index]; 240 + marginTop: 20,
141 - currentLevel = target.level; 241 + marginBottom: 30,
142 - currentParent = index > 0 ? breadcrumb[index - 1].codigo : null; 242 + style: { background: 'transparent', fontFamily: 'var(--font-sans)' },
143 - 243 + x: { label: null, tickFormat: d => d },
144 - // Si navegamos a un nivel intermedio, necesitamos el padre correcto 244 + y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
145 - if (index > 0) { 245 + marks: [
146 - currentParent = breadcrumb[index].codigo; 246 + Plot.areaY(historiaGastos, { x: 'gestion', y: 'monto', fill: '#C9A751', fillOpacity: 0.3 }),
147 - // Buscar el nivel siguiente 247 + Plot.lineY(historiaGastos, { x: 'gestion', y: 'monto', stroke: '#C9A751', strokeWidth: 2 }),
148 - const levels = ['tipo', 'clase', 'cuenta', 'subcuenta']; 248 + Plot.dot(historiaGastos, { x: 'gestion', y: 'monto', fill: '#C9A751', r: 3 })
149 - const nextLevelIndex = levels.indexOf(target.level) + 1; 249 + ]
150 - if (nextLevelIndex < levels.length) { 250 + });
151 - currentLevel = levels[nextLevelIndex]; 251 + chartGastosContainer.appendChild(plot);
152 - }
153 - }
154 -
155 - breadcrumb = breadcrumb.slice(0, index + 1);
156 } 252 }
157 } 253 }
158 254
159 - // Efecto para observar tamaño 255 + // Efectos
160 $effect(() => { 256 $effect(() => {
161 - if (!container) return; 257 + setTimeout(() => {
162 - 258 + clasificadoresGasto.forEach(({ key, data }) => {
163 - const observer = new ResizeObserver(entries => { 259 + if (barContainers[key]) renderizarBarras(key, data);
164 - for (const entry of entries) { 260 + });
165 - width = entry.contentRect.width; 261 + clasificadoresIngreso.forEach(({ key, data }) => {
166 - } 262 + if (barContainers[key]) renderizarBarras(key, data);
167 - }); 263 + });
168 - 264 + }, 100);
169 - observer.observe(container);
170 - return () => observer.disconnect();
171 }); 265 });
172 266
173 - onMount(() => { 267 + $effect(() => {
174 - loadData(); 268 + if (chartIngresosContainer && chartGastosContainer) {
269 + setTimeout(renderizarHistoria, 150);
270 + }
175 }); 271 });
176 272
177 - // Datos preparados reactivos 273 + onMount(() => {
178 - let prepared = $derived(prepareStreamData()); 274 + // Observer para tema
275 + const observer = new MutationObserver(() => {
276 + clasificadoresGasto.forEach(({ key, data }) => {
277 + if (barContainers[key]) renderizarBarras(key, data);
278 + });
279 + clasificadoresIngreso.forEach(({ key, data }) => {
280 + if (barContainers[key]) renderizarBarras(key, data);
281 + });
282 + });
283 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
179 284
180 - // Escalas reactivas 285 + // Resize observer para charts
181 - let xScale = $derived( 286 + const resizeObserver = new ResizeObserver(() => {
182 - d3.scaleLinear() 287 + renderizarHistoria();
183 - .domain(d3.extent(prepared.years)) 288 + });
184 - .range([60, width - 20]) 289 + if (chartIngresosContainer) resizeObserver.observe(chartIngresosContainer);
185 - ); 290 + if (chartGastosContainer) resizeObserver.observe(chartGastosContainer);
186 291
187 - let yExtent = $derived(() => { 292 + return () => {
188 - if (!prepared.series.length) return [0, 0]; 293 + observer.disconnect();
189 - const allValues = prepared.series.flatMap(s => s.flatMap(d => [d[0], d[1]])); 294 + resizeObserver.disconnect();
190 - return d3.extent(allValues); 295 + };
191 }); 296 });
192 -
193 - let yScale = $derived(
194 - d3.scaleLinear()
195 - .domain(yExtent())
196 - .range([height - 40, 40])
197 - );
198 -
199 - // Generador de área
200 - let areaGenerator = $derived(
201 - d3.area()
202 - .x(d => xScale(d.data.año))
203 - .y0(d => yScale(d[0]))
204 - .y1(d => yScale(d[1]))
205 - .curve(d3.curveBasis)
206 - );
207 </script> 297 </script>
208 298
209 <svelte:head> 299 <svelte:head>
210 - <title>Streamgraph - Ingresos del Estado</title> 300 + <title>{entidad.nombre} - Radiografía</title>
211 </svelte:head> 301 </svelte:head>
212 302
213 -<div class="page"> 303 +<div class="dashboard">
214 - <header> 304 + <!-- Header -->
215 - <h1>Ingresos del Estado Boliviano</h1> 305 + <header class="dashboard-header">
216 - <p class="subtitle">Streamgraph interactivo con drilldown · 2005-2025</p> 306 + <div class="header-info">
307 + <h1 class="entidad-nombre">{entidad.nombre}</h1>
308 + <div class="entidad-meta">
309 + <span class="entidad-sigla">{entidad.sigla}</span>
310 + <span class="meta-sep">·</span>
311 + <span class="entidad-sector">{entidad.sector}</span>
312 + </div>
313 + </div>
314 + <div class="header-control">
315 + <label class="gestion-label">Gestión</label>
316 + <select class="gestion-select" bind:value={gestionSeleccionada}>
317 + {#each gestiones as g}
318 + <option value={g}>{g}</option>
319 + {/each}
320 + </select>
321 + </div>
217 </header> 322 </header>
218 323
219 - <!-- Breadcrumb --> 324 + <!-- Ranking -->
220 - <nav class="breadcrumb"> 325 + <div class="ranking-bar">
221 - {#each breadcrumb as crumb, i} 326 + <div class="ranking-item">
222 - {#if i > 0} 327 + <span class="ranking-label">Ranking gastos</span>
223 - <span class="separator">→</span> 328 + <span class="ranking-value">#{ranking.gastos.posicion}</span>
224 - {/if} 329 + <span class="ranking-total">de {ranking.gastos.total}</span>
225 - <button 330 + </div>
226 - class="crumb" 331 + <div class="ranking-sep"></div>
227 - class:active={i === breadcrumb.length - 1} 332 + <div class="ranking-item">
228 - onclick={() => navigateTo(i)} 333 + <span class="ranking-label">Ranking ingresos</span>
229 - disabled={i === breadcrumb.length - 1} 334 + <span class="ranking-value">#{ranking.ingresos.posicion}</span>
230 - > 335 + <span class="ranking-total">de {ranking.ingresos.total}</span>
231 - {crumb.descripcion} 336 + </div>
232 - </button>
233 - {/each}
234 - </nav>
235 -
236 - <!-- Nivel actual -->
237 - <div class="level-indicator">
238 - Nivel: <strong>{currentLevel}</strong>
239 - {#if currentLevel !== 'subcuenta'}
240 - <span class="hint">· Clic en una banda para ver detalle</span>
241 - {/if}
242 - </div>
243 -
244 - <!-- Contenedor del gráfico -->
245 - <div class="chart-container" bind:this={container}>
246 - {#if loading}
247 - <div class="loading">Cargando datos...</div>
248 - {:else if width > 0 && prepared.series.length > 0}
249 - <svg {width} {height}>
250 - <!-- Eje X -->
251 - <g class="axis-x" transform="translate(0, {height - 35})">
252 - {#each prepared.years.filter((_, i) => i % 2 === 0) as year}
253 - <text x={xScale(year)} y="20" text-anchor="middle" class="axis-label">
254 - {year}
255 - </text>
256 - <line
257 - x1={xScale(year)}
258 - y1="-{height - 80}"
259 - x2={xScale(year)}
260 - y2="0"
261 - class="grid-line"
262 - />
263 - {/each}
264 - </g>
265 -
266 - <!-- Streams -->
267 - {#each prepared.series as serie, i}
268 - {@const color = colorScale(serie.key)}
269 - <path
270 - d={areaGenerator(serie)}
271 - fill={color}
272 - fill-opacity="0.85"
273 - stroke={color}
274 - stroke-width="0.5"
275 - class="stream-path"
276 - class:clickable={currentLevel !== 'subcuenta'}
277 - onmouseenter={(e) => {
278 - tooltip = {
279 - show: true,
280 - x: e.clientX,
281 - y: e.clientY,
282 - data: {
283 - codigo: serie.key,
284 - descripcion: serie.descripcion,
285 - color,
286 - total: d3.sum(serie, d => d[1] - d[0])
287 - }
288 - };
289 - }}
290 - onmousemove={(e) => {
291 - tooltip.x = e.clientX;
292 - tooltip.y = e.clientY;
293 - }}
294 - onmouseleave={() => tooltip.show = false}
295 - onclick={() => handleClick(+serie.key, serie.descripcion)}
296 - />
297 - {/each}
298 - </svg>
299 -
300 - <!-- Leyenda -->
301 - <div class="legend">
302 - {#each prepared.series as serie}
303 - {@const color = colorScale(serie.key)}
304 - <button
305 - class="legend-item"
306 - onclick={() => handleClick(+serie.key, serie.descripcion)}
307 - >
308 - <span class="legend-color" style="background: {color}"></span>
309 - <span class="legend-label">{serie.descripcion}</span>
310 - </button>
311 - {/each}
312 - </div>
313 - {:else}
314 - <div class="no-data">No hay datos para este nivel</div>
315 - {/if}
316 </div> 337 </div>
317 338
318 - <!-- Tooltip --> 339 + <!-- Historia temporal -->
319 - {#if tooltip.show && tooltip.data} 340 + <section class="seccion">
320 - <div 341 + <h2 class="seccion-titulo">Historia 2005 – 2025</h2>
321 - class="tooltip" 342 + <div class="historia-grid">
322 - style="left: {tooltip.x + 15}px; top: {tooltip.y - 10}px;" 343 + <div class="historia-card">
323 - > 344 + <div class="historia-header">
324 - <div class="tooltip-header" style="border-color: {tooltip.data.color}"> 345 + <span class="historia-label">Ingresos</span>
325 - <span class="tooltip-code">{tooltip.data.codigo}</span> 346 + <span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].monto)} Bs</span>
326 - <span class="tooltip-desc">{tooltip.data.descripcion}</span> 347 + </div>
348 + <div class="historia-chart" bind:this={chartIngresosContainer}></div>
327 </div> 349 </div>
328 - <div class="tooltip-value"> 350 + <div class="historia-card">
329 - Total acumulado: {formatMoney(tooltip.data.total)} 351 + <div class="historia-header">
352 + <span class="historia-label">Gastos</span>
353 + <span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].monto)} Bs</span>
354 + </div>
355 + <div class="historia-chart" bind:this={chartGastosContainer}></div>
330 </div> 356 </div>
331 - {#if currentLevel !== 'subcuenta'}
332 - <div class="tooltip-hint">Clic para ver detalle</div>
333 - {/if}
334 </div> 357 </div>
335 - {/if} 358 + </section>
359 +
360 + <!-- Composición del gasto -->
361 + <section class="seccion">
362 + <h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
363 + <div class="clasificadores-grid">
364 + {#each clasificadoresGasto as { key, label, data }}
365 + {@const displayItem = getDisplayItem(key, data)}
366 + {@const total = getTotal(data)}
367 + <div class="clasificador-card">
368 + <div class="clasificador-header">
369 + <div class="clasificador-info">
370 + <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
371 + <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
372 + <span class="clasificador-padre">({displayItem?.padre || ''})</span>
373 + </div>
374 + <span class="clasificador-label">{label}</span>
375 + </div>
376 + <div class="barra-container" bind:this={barContainers[key]}></div>
377 + </div>
378 + {/each}
379 + </div>
380 + </section>
381 +
382 + <!-- Origen del dinero -->
383 + <section class="seccion">
384 + <h2 class="seccion-titulo">¿De dónde viene la plata? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
385 + <div class="clasificadores-grid">
386 + {#each clasificadoresIngreso as { key, label, data }}
387 + {@const displayItem = getDisplayItem(key, data)}
388 + {@const total = getTotal(data)}
389 + <div class="clasificador-card">
390 + <div class="clasificador-header">
391 + <div class="clasificador-info">
392 + <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
393 + <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
394 + <span class="clasificador-padre">({displayItem?.padre || ''})</span>
395 + </div>
396 + <span class="clasificador-label">{label}</span>
397 + </div>
398 + <div class="barra-container" bind:this={barContainers[key]}></div>
399 + </div>
400 + {/each}
401 + </div>
402 + </section>
336 </div> 403 </div>
337 404
338 <style> 405 <style>
339 - .page { 406 + .dashboard {
340 min-height: 100vh; 407 min-height: 100vh;
341 - background: #1a1a1a; 408 + background: var(--theme-fondo);
342 - color: #f5f5f5; 409 + color: var(--theme-texto);
343 padding: 2rem; 410 padding: 2rem;
344 - font-family: system-ui, -apple-system, sans-serif; 411 + font-family: var(--font-sans);
345 } 412 }
346 413
347 - header { 414 + /* Header */
348 - text-align: center; 415 + .dashboard-header {
349 - margin-bottom: 2rem; 416 + display: flex;
417 + justify-content: space-between;
418 + align-items: flex-start;
419 + gap: 2rem;
420 + margin-bottom: 1.5rem;
421 + padding-bottom: 1.5rem;
422 + border-bottom: 1px solid var(--theme-borde);
350 } 423 }
351 424
352 - h1 { 425 + .entidad-nombre {
353 - font-size: 2rem; 426 + font-size: 1.75rem;
354 font-weight: 700; 427 font-weight: 700;
355 - margin: 0; 428 + color: var(--theme-titulo);
356 - color: #f5f5f5; 429 + margin: 0 0 0.5rem 0;
430 + line-height: 1.2;
431 + }
432 +
433 + .entidad-meta {
434 + display: flex;
435 + align-items: center;
436 + gap: 0.5rem;
437 + font-size: 0.9rem;
438 + color: var(--theme-texto);
439 + opacity: 0.7;
440 + }
441 +
442 + .entidad-sigla {
443 + font-weight: 600;
444 + color: var(--theme-titulo);
445 + opacity: 1;
446 + }
447 +
448 + .meta-sep {
449 + opacity: 0.4;
450 + }
451 +
452 + .header-control {
453 + display: flex;
454 + flex-direction: column;
455 + gap: 0.25rem;
357 } 456 }
358 457
359 - .subtitle { 458 + .gestion-label {
360 - color: #888; 459 + font-size: 0.7rem;
361 - margin-top: 0.5rem; 460 + text-transform: uppercase;
461 + letter-spacing: 0.05em;
462 + color: var(--theme-texto);
463 + opacity: 0.6;
362 } 464 }
363 465
364 - .breadcrumb { 466 + .gestion-select {
467 + padding: 0.5rem 1rem;
468 + font-size: 0.95rem;
469 + font-weight: 600;
470 + background: var(--theme-tarjeta);
471 + border: 1px solid var(--theme-borde);
472 + border-radius: 8px;
473 + color: var(--theme-titulo);
474 + cursor: pointer;
475 + }
476 +
477 + /* Ranking */
478 + .ranking-bar {
365 display: flex; 479 display: flex;
366 align-items: center; 480 align-items: center;
481 + gap: 1.5rem;
482 + padding: 1rem 1.5rem;
483 + background: var(--theme-tarjeta);
484 + border-radius: 12px;
485 + margin-bottom: 2rem;
486 + }
487 +
488 + .ranking-item {
489 + display: flex;
490 + align-items: baseline;
367 gap: 0.5rem; 491 gap: 0.5rem;
368 - margin-bottom: 1rem;
369 - flex-wrap: wrap;
370 } 492 }
371 493
372 - .separator { 494 + .ranking-label {
373 - color: #555; 495 + font-size: 0.8rem;
496 + color: var(--theme-texto);
497 + opacity: 0.7;
374 } 498 }
375 499
376 - .crumb { 500 + .ranking-value {
377 - background: #2a2a2a; 501 + font-size: 1.25rem;
378 - border: 1px solid #333; 502 + font-weight: 700;
379 - color: #aaa; 503 + color: #C9A751;
380 - padding: 0.4rem 0.8rem;
381 - border-radius: 6px;
382 - cursor: pointer;
383 - font-size: 0.85rem;
384 - transition: all 0.2s;
385 } 504 }
386 505
387 - .crumb:hover:not(:disabled) { 506 + .ranking-total {
388 - background: #333; 507 + font-size: 0.8rem;
389 - color: #fff; 508 + color: var(--theme-texto);
509 + opacity: 0.5;
390 } 510 }
391 511
392 - .crumb.active { 512 + .ranking-sep {
393 - background: #C9A751; 513 + width: 1px;
394 - color: #1a1a1a; 514 + height: 24px;
395 - font-weight: 600; 515 + background: var(--theme-borde);
396 - cursor: default;
397 } 516 }
398 517
399 - .crumb:disabled { 518 + /* Secciones */
400 - cursor: default; 519 + .seccion {
520 + margin-bottom: 2.5rem;
401 } 521 }
402 522
403 - .level-indicator { 523 + .seccion-titulo {
404 - color: #666; 524 + font-size: 1.1rem;
405 - font-size: 0.85rem; 525 + font-weight: 600;
406 - margin-bottom: 1rem; 526 + color: var(--theme-titulo);
527 + margin: 0 0 1rem 0;
528 + display: flex;
529 + align-items: center;
530 + gap: 0.75rem;
407 } 531 }
408 532
409 - .level-indicator strong { 533 + .gestion-badge {
534 + font-size: 0.75rem;
535 + font-weight: 500;
536 + padding: 0.25rem 0.6rem;
537 + background: rgba(201, 167, 81, 0.15);
410 color: #C9A751; 538 color: #C9A751;
411 - text-transform: uppercase; 539 + border-radius: 4px;
412 } 540 }
413 541
414 - .hint { 542 + /* Historia */
415 - color: #555; 543 + .historia-grid {
544 + display: grid;
545 + grid-template-columns: repeat(2, 1fr);
546 + gap: 1.5rem;
416 } 547 }
417 548
418 - .chart-container { 549 + .historia-card {
419 - background: #222; 550 + background: var(--theme-tarjeta);
420 border-radius: 12px; 551 border-radius: 12px;
421 padding: 1rem; 552 padding: 1rem;
422 - min-height: 500px;
423 - position: relative;
424 } 553 }
425 554
426 - .loading, .no-data { 555 + .historia-header {
427 display: flex; 556 display: flex;
428 - align-items: center; 557 + justify-content: space-between;
429 - justify-content: center; 558 + align-items: baseline;
430 - height: 400px; 559 + margin-bottom: 0.75rem;
431 - color: #666;
432 } 560 }
433 561
434 - svg { 562 + .historia-label {
435 - display: block; 563 + font-size: 0.85rem;
564 + font-weight: 500;
565 + color: var(--theme-texto);
436 } 566 }
437 567
438 - .axis-label { 568 + .historia-monto {
439 - fill: #666; 569 + font-size: 0.95rem;
440 - font-size: 12px; 570 + font-weight: 600;
441 } 571 }
442 572
443 - .grid-line { 573 + .historia-monto.ingresos {
444 - stroke: #333; 574 + color: #4A8B6E;
445 - stroke-width: 0.5;
446 - stroke-dasharray: 2, 4;
447 } 575 }
448 576
449 - .stream-path { 577 + .historia-monto.gastos {
450 - transition: fill-opacity 0.2s, transform 0.2s; 578 + color: #C9A751;
451 } 579 }
452 580
453 - .stream-path:hover { 581 + .historia-chart {
454 - fill-opacity: 1; 582 + width: 100%;
583 + height: 180px;
455 } 584 }
456 585
457 - .stream-path.clickable { 586 + /* Clasificadores */
458 - cursor: pointer; 587 + .clasificadores-grid {
588 + display: flex;
589 + flex-direction: column;
590 + gap: 1rem;
459 } 591 }
460 592
461 - .stream-path.clickable:hover { 593 + .clasificador-card {
462 - filter: brightness(1.1); 594 + display: flex;
595 + flex-direction: column;
596 + gap: 0.35rem;
463 } 597 }
464 598
465 - .legend { 599 + .clasificador-header {
466 display: flex; 600 display: flex;
467 - flex-wrap: wrap; 601 + align-items: baseline;
468 - gap: 0.5rem; 602 + justify-content: space-between;
469 - margin-top: 1rem; 603 + gap: 1rem;
470 - padding-top: 1rem;
471 - border-top: 1px solid #333;
472 } 604 }
473 605
474 - .legend-item { 606 + .clasificador-info {
475 display: flex; 607 display: flex;
476 - align-items: center; 608 + align-items: baseline;
477 gap: 0.4rem; 609 gap: 0.4rem;
478 - background: #2a2a2a; 610 + flex-wrap: wrap;
479 - border: 1px solid #333;
480 - padding: 0.3rem 0.6rem;
481 - border-radius: 4px;
482 - cursor: pointer;
483 - transition: all 0.2s;
484 } 611 }
485 612
486 - .legend-item:hover { 613 + .clasificador-monto {
487 - background: #333; 614 + font-size: 0.95rem;
615 + font-weight: 600;
616 + color: var(--theme-titulo);
488 } 617 }
489 618
490 - .legend-color { 619 + .clasificador-nombre {
491 - width: 12px; 620 + font-size: 0.85rem;
492 - height: 12px; 621 + font-weight: 500;
493 - border-radius: 2px; 622 + color: var(--theme-titulo);
494 - flex-shrink: 0;
495 } 623 }
496 624
497 - .legend-label { 625 + .clasificador-padre {
498 - font-size: 0.75rem; 626 + font-size: 0.7rem;
499 - color: #aaa; 627 + color: var(--theme-texto);
500 - max-width: 150px; 628 + opacity: 0.5;
501 - overflow: hidden;
502 - text-overflow: ellipsis;
503 - white-space: nowrap;
504 } 629 }
505 630
506 - .tooltip { 631 + .clasificador-label {
507 - position: fixed; 632 + font-size: 0.65rem;
508 - background: rgba(30, 30, 30, 0.95); 633 + text-transform: uppercase;
509 - backdrop-filter: blur(8px); 634 + letter-spacing: 0.05em;
510 - border: 1px solid #444; 635 + color: var(--theme-texto);
511 - border-radius: 8px; 636 + opacity: 0.6;
512 - padding: 0.75rem 1rem; 637 + flex-shrink: 0;
513 - pointer-events: none;
514 - z-index: 1000;
515 - max-width: 300px;
516 - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
517 } 638 }
518 639
519 - .tooltip-header { 640 + .barra-container {
520 - border-left: 3px solid; 641 + width: 100%;
521 - padding-left: 0.5rem; 642 + height: 48px;
522 - margin-bottom: 0.5rem; 643 + border-radius: 4px;
644 + overflow: hidden;
523 } 645 }
524 646
525 - .tooltip-code { 647 + /* Responsive */
526 - font-family: monospace; 648 + @media (max-width: 768px) {
527 - font-size: 0.75rem; 649 + .dashboard {
528 - color: #888; 650 + padding: 1rem;
529 - display: block; 651 + }
530 - }
531 652
532 - .tooltip-desc { 653 + .dashboard-header {
533 - font-size: 0.9rem; 654 + flex-direction: column;
534 - color: #f5f5f5; 655 + gap: 1rem;
535 - font-weight: 500; 656 + }
536 - }
537 657
538 - .tooltip-value { 658 + .entidad-nombre {
539 - font-size: 0.85rem; 659 + font-size: 1.35rem;
540 - color: #C9A751; 660 + }
541 - font-weight: 600;
542 - }
543 661
544 - .tooltip-hint { 662 + .historia-grid {
545 - font-size: 0.75rem; 663 + grid-template-columns: 1fr;
546 - color: #666; 664 + }
547 - margin-top: 0.3rem; 665 +
666 + .ranking-bar {
667 + flex-direction: column;
668 + align-items: flex-start;
669 + gap: 0.75rem;
670 + }
671 +
672 + .ranking-sep {
673 + width: 100%;
674 + height: 1px;
675 + }
548 } 676 }
549 </style> 677 </style>
......
1 -# Estilos UI - Dark Mode, Menu y Drawer
2 -
3 -## 1. Variables CSS (app.css)
4 -
5 -```css
6 -/* Light mode colors */
7 -:root {
8 - --color-light-body: #f5ebe0;
9 - --color-light-background: #f3ead8;
10 - --color-light-titulo: #363534;
11 - --color-light-texto-auxiliar: #676767;
12 - --color-light-fill: #f3ead8;
13 - --color-light-borde: rgba(0, 0, 0, 0.08);
14 - --color-light-focus-secondary: #676767;
15 - --color-light-accent: #c9a751;
16 -}
17 -
18 -/* Dark mode colors */
19 -:root.dark {
20 - --color-light-body: #0d0d0d;
21 - --color-light-background: #1a1a1a;
22 - --color-light-titulo: #e8e4d8;
23 - --color-light-texto-auxiliar: #b8b3a3;
24 - --color-light-fill: #2d2d2d;
25 - --color-light-borde: rgba(255, 255, 255, 0.1);
26 - --color-light-focus-secondary: #c9a751;
27 - --color-light-accent: #c9a751;
28 -}
29 -```
30 -
31 ----
32 -
33 -## 2. Inicialización del tema (app.html)
34 -
35 -Evita el flash de tema incorrecto cargando la preferencia antes del render.
36 -
37 -```html
38 -<!doctype html>
39 -<html lang="es">
40 - <head>
41 - <meta charset="utf-8" />
42 - <meta name="viewport" content="width=device-width, initial-scale=1" />
43 -
44 - <!-- Theme initialization - ANTES de cargar CSS -->
45 - <script>
46 - (function() {
47 - const savedTheme = localStorage.getItem('theme');
48 - let isDark = false;
49 -
50 - if (savedTheme) {
51 - isDark = savedTheme === 'dark';
52 - }
53 -
54 - if (isDark) {
55 - document.documentElement.classList.add('dark');
56 - }
57 - })();
58 - </script>
59 - </head>
60 - <body>
61 - <!-- contenido -->
62 - </body>
63 -</html>
64 -```
65 -
66 ----
67 -
68 -## 3. BARRA DE CONTROLES (Pastilla con iconos)
69 -
70 -Esta es la barra que contiene: **Home + Theme Toggle + Menú Hamburguesa**
71 -
72 -### Estructura completa
73 -
74 -```svelte
75 -<script>
76 - import { onMount } from 'svelte';
77 -
78 - let isDark = $state(false);
79 - let drawerOpen = $state(false);
80 -
81 - function toggleTheme() {
82 - isDark = !isDark;
83 - document.documentElement.classList.toggle('dark', isDark);
84 - localStorage.setItem('theme', isDark ? 'dark' : 'light');
85 - }
86 -
87 - function toggleDrawer() {
88 - drawerOpen = !drawerOpen;
89 - }
90 -
91 - onMount(() => {
92 - const savedTheme = localStorage.getItem('theme');
93 - if (savedTheme) {
94 - isDark = savedTheme === 'dark';
95 - }
96 - document.documentElement.classList.toggle('dark', isDark);
97 - });
98 -</script>
99 -
100 -<!-- BARRA DE CONTROLES - Esquina superior derecha -->
101 -<div class="fixed top-6 right-6 z-[110]">
102 - <div class="flex items-center gap-1">
103 -
104 - <!-- ========== BOTÓN HOME (4 cuadrados) ========== -->
105 - <div class="relative group/home">
106 - <a
107 - href="/"
108 - class="p-2 rounded-full transition-all block"
109 - style="background-color: transparent; color: var(--color-light-texto-auxiliar);"
110 - onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
111 - onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
112 - >
113 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
114 - <rect x="2" y="2" width="7" height="7" rx="1.5"/>
115 - <rect x="11" y="2" width="7" height="7" rx="1.5"/>
116 - <rect x="2" y="11" width="7" height="7" rx="1.5"/>
117 - <rect x="11" y="11" width="7" height="7" rx="1.5"/>
118 - </svg>
119 - </a>
120 - <!-- Tooltip -->
121 - <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">
122 - Página principal
123 - <div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
124 - </div>
125 - </div>
126 -
127 - <!-- ========== BOTÓN THEME TOGGLE (Sol/Luna) ========== -->
128 - <div class="relative group/theme">
129 - <button
130 - onclick={toggleTheme}
131 - class="p-2 rounded-full transition-all"
132 - style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
133 - onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
134 - onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
135 - aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
136 - >
137 - {#if isDark}
138 - <!-- LUNA (modo oscuro activo) -->
139 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
140 - <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
141 - </svg>
142 - {:else}
143 - <!-- SOL (modo claro activo) -->
144 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
145 - <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"/>
146 - </svg>
147 - {/if}
148 - </button>
149 - <!-- Tooltip -->
150 - <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">
151 - {isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
152 - <div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
153 - </div>
154 - </div>
155 -
156 - <!-- ========== BOTÓN MENÚ HAMBURGUESA ========== -->
157 - <div class="relative group/menu">
158 - <button
159 - onclick={toggleDrawer}
160 - class="p-2 rounded-full transition-all"
161 - style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
162 - onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
163 - onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
164 - aria-label="Abrir menú"
165 - >
166 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
167 - <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
168 - </svg>
169 - </button>
170 - <!-- Tooltip -->
171 - <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">
172 - Menú
173 - <div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
174 - </div>
175 - </div>
176 -
177 - </div>
178 -</div>
179 -```
180 -
181 -### Estilos clave de cada botón
182 -
183 -| Propiedad | Valor |
184 -|-----------|-------|
185 -| Padding | `p-2` (8px) |
186 -| Forma | `rounded-full` (circular) |
187 -| Fondo | `transparent` |
188 -| Color normal | `var(--color-light-texto-auxiliar)` |
189 -| Color hover | `var(--color-light-titulo)` |
190 -| Tamaño icono | `w-5 h-5` (20px) |
191 -| Transición | `transition-all` |
192 -| Cursor | `cursor: pointer` |
193 -
194 -### Estilos del contenedor (la "pastilla")
195 -
196 -| Propiedad | Valor |
197 -|-----------|-------|
198 -| Layout | `flex items-center` |
199 -| Espaciado | `gap-1` (4px entre iconos) |
200 -| Posición | `fixed top-6 right-6` |
201 -| Z-index | `z-[110]` |
202 -
203 -### Tooltip (aparece abajo del icono)
204 -
205 -| Propiedad | Valor |
206 -|-----------|-------|
207 -| Posición | `absolute right-0 top-full mt-2` |
208 -| Fondo | `bg-gray-900/90` |
209 -| Texto | `text-white text-xs` |
210 -| Padding | `px-3 py-1.5` |
211 -| Bordes | `rounded-lg` |
212 -| Efecto | `backdrop-blur-sm` |
213 -| Transición | `opacity-0 invisible``opacity-100 visible` en hover |
214 -| Flecha | `w-2 h-2 bg-gray-900/90 rotate-45` posicionada arriba |
215 -
216 ----
217 -
218 -## 4. VERSIÓN MÓVIL (Solo hamburguesa + theme en drawer)
219 -
220 -En móvil, solo se muestra el botón hamburguesa. El theme toggle va dentro del drawer.
221 -
222 -```svelte
223 -<!-- Móvil: solo hamburguesa arriba a la derecha -->
224 -<div class="fixed top-2 right-2 z-[110] md:hidden">
225 - <button
226 - onclick={toggleDrawer}
227 - class="p-2 rounded-full transition-all flex-shrink-0"
228 - style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
229 - onmouseenter={(e) => e.currentTarget.style.color = 'var(--color-light-titulo)'}
230 - onmouseleave={(e) => e.currentTarget.style.color = 'var(--color-light-texto-auxiliar)'}
231 - aria-label="Abrir menú"
232 - >
233 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
234 - <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
235 - </svg>
236 - </button>
237 -</div>
238 -
239 -<!-- Desktop: barra completa con home + theme + menu -->
240 -<div class="hidden md:flex fixed top-6 right-6 z-[110]">
241 - <!-- ... los 3 botones ... -->
242 -</div>
243 -```
244 -
245 ----
246 -
247 -## 5. NavigationDrawer.svelte (Drawer completo)
248 -
249 -```svelte
250 -<script>
251 - import { onMount } from 'svelte';
252 -
253 - let { isOpen = $bindable(false) } = $props();
254 - let isDarkMode = $state(false);
255 -
256 - // Detectar cambios de tema
257 - onMount(() => {
258 - isDarkMode = document.documentElement.classList.contains('dark');
259 -
260 - const observer = new MutationObserver(() => {
261 - isDarkMode = document.documentElement.classList.contains('dark');
262 - });
263 - observer.observe(document.documentElement, {
264 - attributes: true,
265 - attributeFilter: ['class']
266 - });
267 - });
268 -
269 - function closeDrawer() {
270 - isOpen = false;
271 - }
272 -
273 - function toggleTheme() {
274 - isDarkMode = !isDarkMode;
275 - document.documentElement.classList.toggle('dark', isDarkMode);
276 - localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
277 - }
278 -</script>
279 -
280 -<!-- Overlay (fondo oscuro) -->
281 -{#if isOpen}
282 - <div
283 - class="fixed inset-0 bg-black/40 z-[200]"
284 - style="opacity: {isOpen ? '1' : '0'}; transition: opacity 0.2s ease-out;"
285 - onclick={closeDrawer}
286 - ></div>
287 -{/if}
288 -
289 -<!-- Drawer -->
290 -<aside
291 - class="fixed top-0 right-0 h-full w-80 border-l shadow-2xl z-[201]"
292 - style="
293 - background-color: var(--color-light-body);
294 - border-color: rgba(0, 0, 0, 0.05);
295 - transform: {isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)'};
296 - transition: transform 0.3s cubic-bezier(0.33, 1, 0.68, 1);
297 - "
298 ->
299 - <div class="flex flex-col h-full">
300 -
301 - <!-- Header del Drawer -->
302 - <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);">
303 -
304 - <!-- Theme Toggle - SOLO en móvil (arriba izquierda del drawer) -->
305 - <div class="absolute top-3 md:top-4 left-4 md:left-6 md:hidden">
306 - <button
307 - onclick={toggleTheme}
308 - class="p-2 rounded-full transition-all"
309 - style="background-color: transparent; color: var(--color-light-texto-auxiliar); cursor: pointer;"
310 - >
311 - {#if isDarkMode}
312 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
313 - <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
314 - </svg>
315 - {:else}
316 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
317 - <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"/>
318 - </svg>
319 - {/if}
320 - </button>
321 - </div>
322 -
323 - <!-- Botón cerrar (X) - arriba derecha -->
324 - <div class="group absolute top-3 md:top-4 right-4 md:right-6">
325 - <button
326 - onclick={closeDrawer}
327 - class="p-2 rounded-lg transition-colors flex-shrink-0"
328 - style="color: var(--color-light-focus-secondary); cursor: pointer;"
329 - aria-label="Cerrar menú"
330 - >
331 - <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
332 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
333 - </svg>
334 - </button>
335 - <!-- Tooltip del botón cerrar -->
336 - <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">
337 - Cerrar menú
338 - <div class="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-gray-900/90 rotate-45"></div>
339 - </div>
340 - </div>
341 -
342 - <!-- Logo centrado (cambia según tema) -->
343 - <div class="flex flex-col items-center">
344 - <img
345 - src={isDarkMode ? "/logos_oscuro/logo.png" : "/logos_claro/logo.png"}
346 - alt="Logo"
347 - class="h-24 md:h-32 w-auto mb-3"
348 - />
349 - </div>
350 - </div>
351 -
352 - <!-- Navigation -->
353 - <nav class="flex-1 overflow-y-auto p-4">
354 -
355 - <!-- Item de navegación con hover -->
356 - <a
357 - href="/"
358 - class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left group"
359 - onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-light-fill)'}
360 - onmouseleave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
361 - >
362 - <svg class="w-4 h-4 flex-shrink-0" style="color: var(--color-light-focus-secondary);" fill="currentColor" viewBox="0 0 20 20">
363 - <rect x="2" y="2" width="7" height="7" rx="1.5"/>
364 - <rect x="11" y="2" width="7" height="7" rx="1.5"/>
365 - <rect x="2" y="11" width="7" height="7" rx="1.5"/>
366 - <rect x="11" y="11" width="7" height="7" rx="1.5"/>
367 - </svg>
368 - <span class="font-medium text-sm" style="color: var(--color-light-titulo);">Página principal</span>
369 - </a>
370 -
371 - <!-- Sección con título -->
372 - <div class="mb-6 mt-4">
373 - <h3 class="text-xs font-semibold uppercase tracking-wider mb-3 px-2" style="color: var(--color-light-focus-secondary);">
374 - Sección
375 - </h3>
376 - <div class="space-y-1">
377 - <!-- Items de navegación aquí -->
378 - </div>
379 - </div>
380 -
381 - </nav>
382 -
383 - <!-- Footer -->
384 - <div class="p-4 border-t flex-shrink-0" style="border-color: rgba(0, 0, 0, 0.05);">
385 - <p class="text-xs text-center" style="color: var(--color-light-focus-secondary);">
386 - © 2026
387 - </p>
388 - </div>
389 -
390 - </div>
391 -</aside>
392 -```
393 -
394 ----
395 -
396 -## 6. Iconos SVG
397 -
398 -### Sol (modo claro activo)
399 -```html
400 -<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
401 - <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"/>
402 -</svg>
403 -```
404 -
405 -### Luna (modo oscuro activo)
406 -```html
407 -<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
408 - <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
409 -</svg>
410 -```
411 -
412 -### Hamburguesa (menú)
413 -```html
414 -<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
415 - <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
416 -</svg>
417 -```
418 -
419 -### X (cerrar)
420 -```html
421 -<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
422 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
423 -</svg>
424 -```
425 -
426 -### Home (4 cuadrados)
427 -```html
428 -<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
429 - <rect x="2" y="2" width="7" height="7" rx="1.5"/>
430 - <rect x="11" y="2" width="7" height="7" rx="1.5"/>
431 - <rect x="2" y="11" width="7" height="7" rx="1.5"/>
432 - <rect x="11" y="11" width="7" height="7" rx="1.5"/>
433 -</svg>
434 -```
435 -
436 ----
437 -
438 -## 7. Resumen de comportamiento
439 -
440 -| Viewport | Qué se ve | Theme toggle |
441 -|----------|-----------|--------------|
442 -| **Móvil** (`<768px`) | Solo hamburguesa arriba derecha | Dentro del drawer (arriba izquierda) |
443 -| **Desktop** (`≥768px`) | Home + Theme + Hamburguesa | En la barra de controles |
444 -
445 -### Animaciones
446 -
447 -| Elemento | Animación |
448 -|----------|-----------|
449 -| Hover en iconos | Color cambia de `texto-auxiliar``titulo` |
450 -| Tooltip | `opacity 0→1`, `invisible→visible` con `duration-200` |
451 -| Drawer abre | `translate3d(100%, 0, 0)``translate3d(0, 0, 0)` con `cubic-bezier(0.33, 1, 0.68, 1)` |
452 -| Overlay | `opacity 0→1` con `0.2s ease-out` |
1 +# Estructura de Bases de Datos - Presupuesto Público Bolivia
2 +
3 +Este documento detalla las tablas, vistas y sus estructuras en el esquema `ppto` de Supabase.
4 +
5 +---
6 +
7 +## Tablas de Clasificadores
8 +
9 +### 1. `clas_institucional` - Clasificador Institucional
10 +
11 +| Columna | Tipo | Descripción |
12 +|---------|------|-------------|
13 +| entidad | INTEGER | Código único de la entidad |
14 +| desc_entidad | TEXT | Nombre completo de la entidad |
15 +| sigla_entidad | VARCHAR(50) | Sigla de la entidad |
16 +| sector | VARCHAR(10) | Código del sector |
17 +| desc_sector | TEXT | Nombre del sector |
18 +| sigla_sector | VARCHAR(20) | Sigla del sector |
19 +| subsector | VARCHAR(10) | Código del subsector |
20 +| desc_subsector | TEXT | Nombre del subsector |
21 +| sigla_subsector | VARCHAR(20) | Sigla del subsector |
22 +| area | VARCHAR(10) | Código del área |
23 +| desc_area | TEXT | Nombre del área |
24 +| sigla_area | VARCHAR(20) | Sigla del área |
25 +| subarea | VARCHAR(10) | Código de la subárea |
26 +| desc_subarea | TEXT | Nombre de la subárea |
27 +| sigla_subarea | VARCHAR(20) | Sigla de la subárea |
28 +| gestiones | TEXT | Lista CSV de años con datos (ej: "2006,2007,...,2025") |
29 +| n_gestiones | INTEGER | Cantidad de años con datos |
30 +
31 +
32 +
33 +---
34 +
35 +### 2. `clas_objetos` - Clasificador de Objetos de Gasto
36 +
37 +| Columna | Tipo | Descripción |
38 +|---------|------|-------------|
39 +| nivel | TEXT | Nivel jerárquico: 'grupo', 'subgrupo', 'partida', 'subpartida' |
40 +| objeto | TEXT (PK) | Código del objeto (ej: "10000", "11000", "11100") |
41 +| desc_objeto | TEXT | Descripción vigente del objeto de gasto |
42 +| gestiones | TEXT | Rangos de años donde existe (ej: "2005, 2007-2020, 2022-2026") |
43 +| n_gestiones | INTEGER | Cantidad de años con datos |
44 +| n_variaciones | INTEGER | Cantidad de variaciones históricas en la descripción |
45 +| descripciones | JSONB | Historial de descripciones por rangos de años |
46 +
47 +**Estructura de `descripciones`:**
48 +```json
49 +[
50 + {
51 + "descripcion": "Descripción vigente del objeto...",
52 + "rangos": "2011-2020, 2022-2026"
53 + },
54 + {
55 + "descripcion": "Descripción anterior del objeto...",
56 + "rangos": "2005, 2007-2010"
57 + }
58 +]
59 +```
60 +
61 +**Jerarquía derivada del código `objeto`:**
62 +```
63 +Nivel Patrón Ejemplo
64 +─────────────────────────────────
65 +grupo X0000 10000, 20000
66 +subgrupo XX000 11000, 12000
67 +partida XXX00 11100, 11200
68 +subpartida XXXX0/X 11110, 11111
69 +```
70 +
71 +**Sin índices.** La tabla tiene ~500 filas y se carga completa al inicio.
72 +
73 +---
74 +
75 +### 3. `clas_rubros` - Clasificador de Rubros (Ingresos)
76 +
77 +| Columna | Tipo | Descripción |
78 +|---------|------|-------------|
79 +| nivel | TEXT | Nivel jerárquico: 'tipo', 'clase', 'cuenta', 'sub_cuenta' |
80 +| rubro | TEXT (PK) | Código del rubro (ej: "1000", "1100", "1110") |
81 +| desc_rubro | TEXT | Descripción vigente del rubro |
82 +| gestiones | TEXT | Rangos de años donde existe (ej: "2005, 2007-2020, 2022-2026") |
83 +| n_gestiones | INTEGER | Cantidad de años con datos |
84 +| n_variaciones | INTEGER | Cantidad de variaciones históricas en la descripción |
85 +| descripciones | JSONB | Historial de descripciones por rangos de años |
86 +
87 +**Estructura de `descripciones`:**
88 +```json
89 +[
90 + {
91 + "descripcion": "Descripción vigente del rubro...",
92 + "rangos": "2011-2020, 2022-2026"
93 + },
94 + {
95 + "descripcion": "Descripción anterior del rubro...",
96 + "rangos": "2005, 2007-2010"
97 + }
98 +]
99 +```
100 +
101 +**Jerarquía derivada del código `rubro`:**
102 +```
103 +Nivel Patrón Ejemplo
104 +──────────────────────────────────
105 +tipo X000 1000, 2000
106 +clase XX00 1100, 1200
107 +cuenta XXX0 1110, 1120
108 +sub_cuenta XXXX 1111, 1112
109 +```
110 +
111 +**Sin índices.** La tabla tiene ~400 filas y se carga completa al inicio.
112 +
113 +---
114 +
115 +### 4. `clas_finfun` - Clasificador de Finalidad y Función
116 +
117 +| Columna | Tipo | Descripción |
118 +|---------|------|-------------|
119 +| nivel | TEXT | Nivel jerárquico: 'finalidad', 'grpfuncion', 'funcion' |
120 +| finfun | TEXT (PK) | Código de finalidad/función (ej: "1", "11", "111") |
121 +| desc_finfun | TEXT | Descripción vigente de la finalidad/función |
122 +| gestiones | TEXT | Rangos de años donde existe (ej: "2005, 2007-2020, 2022-2026") |
123 +| n_gestiones | INTEGER | Cantidad de años con datos |
124 +| n_variaciones | INTEGER | Cantidad de variaciones históricas en la descripción |
125 +| descripciones | JSONB | Historial de descripciones por rangos de años |
126 +
127 +**Estructura de `descripciones`:**
128 +```json
129 +[
130 + {
131 + "descripcion": "Descripción vigente de la finalidad/función...",
132 + "rangos": "2011-2020, 2022-2026"
133 + },
134 + {
135 + "descripcion": "Descripción anterior...",
136 + "rangos": "2005, 2007-2010"
137 + }
138 +]
139 +```
140 +
141 +**Jerarquía derivada del código `finfun`:**
142 +```
143 +Nivel Patrón Ejemplo
144 +──────────────────────────────────
145 +finalidad X 1, 2, 3
146 +grpfuncion XX 11, 12, 21
147 +funcion XXX+ 111, 112, 211
148 +```
149 +
150 +**Sin índices.** La tabla tiene ~200 filas y se carga completa al inicio.
151 +
152 +---
153 +
154 +## Tablas de Datos Agregados
155 +
156 +### 5. `treemap_objeto` - Datos para Treemap de Objetos de Gasto
157 +
158 +| Columna | Tipo | Descripción |
159 +|---------|------|-------------|
160 +| gestion | INTEGER | Año fiscal (2005-2025) |
161 +| entidad | INTEGER | Código de entidad (0 = Todo el Estado) |
162 +| nivel | VARCHAR(20) | Nivel: 'grupo', 'subgrupo', 'partida', 'subpartida' |
163 +| objeto | INTEGER | Código del objeto |
164 +| desc_objeto | TEXT | Descripción del objeto |
165 +| parent | INTEGER | Código del objeto padre (para jerarquía) |
166 +| devengado | NUMERIC | Monto devengado en Bs |
167 +
168 +**Índices recomendados:**
169 +```sql
170 +CREATE INDEX idx_treemap_objeto_gestion_entidad ON ppto.treemap_objeto(gestion, entidad);
171 +```
172 +
173 +**Notas:**
174 +- ~1.4 millones de filas
175 +- Entidad 0 representa el agregado de todo el estado
176 +- Cada combinación gestión + entidad debe cargarse como unidad
177 +
178 +---
179 +
180 +### 6. `entidades_treemap` - Índice de Entidades por Año (Treemap)
181 +
182 +| Columna | Tipo | Descripción |
183 +|---------|------|-------------|
184 +| entidad | INTEGER | Código de la entidad |
185 +| gestion | INTEGER | Año fiscal |
186 +| desc_entidad | TEXT | Nombre de la entidad |
187 +| sigla_entidad | VARCHAR(50) | Sigla de la entidad |
188 +
189 +**Índices recomendados:**
190 +```sql
191 +CREATE INDEX idx_entidades_treemap_gestion ON ppto.entidades_treemap(gestion);
192 +```
193 +
194 +**SQL para crear (derivada de treemap_objeto):**
195 +```sql
196 +CREATE TABLE ppto.entidades_treemap AS
197 +SELECT DISTINCT
198 + t.entidad,
199 + t.gestion,
200 + c.desc_entidad,
201 + c.sigla_entidad
202 +FROM ppto.treemap_objeto t
203 +LEFT JOIN ppto.clas_institucional c ON t.entidad = c.entidad
204 +WHERE t.entidad > 0;
205 +
206 +CREATE INDEX idx_entidades_treemap_gestion ON ppto.entidades_treemap(gestion);
207 +GRANT SELECT ON ppto.entidades_treemap TO anon, authenticated;
208 +```
209 +
210 +**Uso:** Poblar dropdown de entidades filtrado por año seleccionado.
211 +
212 +---
213 +
214 +### 7. `entidades_por_objeto` - Relación Entidades-Objetos
215 +
216 +| Columna | Tipo | Descripción |
217 +|---------|------|-------------|
218 +| entidad | INTEGER | Código de la entidad |
219 +| entidad_desc | TEXT | Nombre de la entidad |
220 +| objeto | INTEGER | Código del objeto |
221 +| nivel | VARCHAR(20) | Nivel del objeto |
222 +
223 +**Índices recomendados:**
224 +```sql
225 +CREATE INDEX idx_entidades_por_objeto_objeto ON ppto.entidades_por_objeto(objeto);
226 +CREATE INDEX idx_entidades_por_objeto_objeto_nivel ON ppto.entidades_por_objeto(objeto, nivel);
227 +```
228 +
229 +**Uso:** Poblar dropdowns de entidades al ver un objeto específico.
230 +
231 +---
232 +
233 +### 8. `vista_finfun_estado` - Vista Agregada por Finalidad/Función (Todo el Estado)
234 +
235 +| Columna | Tipo | Descripción |
236 +|---------|------|-------------|
237 +| gestion | INTEGER | Año fiscal |
238 +| finfun | TEXT | Código de finalidad/función |
239 +| total | NUMERIC | Monto total devengado |
240 +| poblacion | INTEGER | Población del año |
241 +| n_entidades | INTEGER | Cantidad de entidades con datos |
242 +| per_capita | NUMERIC | Gasto per cápita (total/población) |
243 +| total_gestion | NUMERIC | Total del Estado en esa gestión |
244 +| prop | NUMERIC | Proporción sobre el total del Estado |
245 +| ranking | INTEGER | Ranking por monto dentro de su nivel |
246 +| n_pares | INTEGER | Cantidad de códigos del mismo nivel |
247 +| top1_entidad | INTEGER | Código de la entidad con mayor gasto |
248 +| top1_entidad_desc | TEXT | Nombre de la entidad top 1 |
249 +| top1_monto | NUMERIC | Monto de la entidad top 1 |
250 +| top1_pct | NUMERIC | Porcentaje del total |
251 +| top2_entidad | INTEGER | Código de la entidad top 2 |
252 +| top2_entidad_desc | TEXT | Nombre de la entidad top 2 |
253 +| top2_monto | NUMERIC | Monto de la entidad top 2 |
254 +| top2_pct | NUMERIC | Porcentaje del total |
255 +| top3_entidad | INTEGER | Código de la entidad top 3 |
256 +| top3_entidad_desc | TEXT | Nombre de la entidad top 3 |
257 +| top3_monto | NUMERIC | Monto de la entidad top 3 |
258 +| top3_pct | NUMERIC | Porcentaje del total |
259 +
260 +**Índices recomendados:**
261 +```sql
262 +CREATE INDEX idx_vista_finfun_estado_finfun ON ppto.vista_finfun_estado(finfun);
263 +CREATE INDEX idx_vista_finfun_estado_finfun_gestion ON ppto.vista_finfun_estado(finfun, gestion);
264 +```
265 +
266 +---
267 +
268 +### 9. `vista_finfun_entidad` - Vista por Finalidad/Función y Entidad
269 +
270 +| Columna | Tipo | Descripción |
271 +|---------|------|-------------|
272 +| gestion | INTEGER | Año fiscal |
273 +| finfun | TEXT | Código de finalidad/función |
274 +| nivel | TEXT | Nivel: 'finalidad', 'grpfuncion', 'funcion' |
275 +| entidad | INTEGER | Código de la entidad |
276 +| entidad_desc | TEXT | Nombre de la entidad |
277 +| monto | NUMERIC | Monto devengado |
278 +| per_capita | NUMERIC | Gasto per cápita |
279 +| ranking | INTEGER | Ranking de la entidad para ese finfun |
280 +| prop | NUMERIC | Proporción sobre el total del finfun |
281 +| n_entidades | INTEGER | Total de entidades con ese finfun |
282 +
283 +**Índices recomendados:**
284 +```sql
285 +CREATE INDEX idx_vista_finfun_entidad_finfun ON ppto.vista_finfun_entidad(finfun);
286 +CREATE INDEX idx_vista_finfun_entidad_finfun_entidad ON ppto.vista_finfun_entidad(finfun, entidad);
287 +```
288 +
289 +---
290 +
291 +### 10. `entidades_por_finfun` - Relación Entidades-Finalidad/Función
292 +
293 +| Columna | Tipo | Descripción |
294 +|---------|------|-------------|
295 +| finfun | TEXT | Código de finalidad/función |
296 +| nivel | TEXT | Nivel: 'finalidad', 'grpfuncion', 'funcion' |
297 +| entidad | INTEGER | Código de la entidad |
298 +| entidad_desc | TEXT | Nombre de la entidad |
299 +
300 +**Índices recomendados:**
301 +```sql
302 +CREATE INDEX idx_entidades_por_finfun_finfun ON ppto.entidades_por_finfun(finfun);
303 +CREATE INDEX idx_entidades_por_finfun_finfun_nivel ON ppto.entidades_por_finfun(finfun, nivel);
304 +```
305 +
306 +**Uso:** Poblar dropdowns de entidades al ver una finalidad/función específica.
307 +
308 +---
309 +
310 +## Vistas Materializadas / Tablas de Consulta
311 +
312 +### 11. `vista_objeto_estado` - Vista Agregada por Objeto (Todo el Estado)
313 +
314 +| Columna | Tipo | Descripción |
315 +|---------|------|-------------|
316 +| objeto | INTEGER | Código del objeto |
317 +| gestion | INTEGER | Año fiscal |
318 +| devengado | NUMERIC | Monto total devengado |
319 +| top1_entidad | TEXT | Entidad con mayor gasto |
320 +| top1_monto | NUMERIC | Monto de la entidad top 1 |
321 +| top1_pct | NUMERIC | Porcentaje del total |
322 +| top2_entidad | TEXT | Segunda entidad con mayor gasto |
323 +| top2_monto | NUMERIC | Monto de la entidad top 2 |
324 +| top2_pct | NUMERIC | Porcentaje del total |
325 +| top3_entidad | TEXT | Tercera entidad con mayor gasto |
326 +| top3_monto | NUMERIC | Monto de la entidad top 3 |
327 +| top3_pct | NUMERIC | Porcentaje del total |
328 +
329 +**Índices recomendados:**
330 +```sql
331 +CREATE INDEX idx_vista_objeto_estado_objeto ON ppto.vista_objeto_estado(objeto);
332 +CREATE INDEX idx_vista_objeto_estado_objeto_gestion ON ppto.vista_objeto_estado(objeto, gestion);
333 +```
334 +
335 +---
336 +
337 +### 12. `vista_objeto_entidad` - Vista por Objeto y Entidad
338 +
339 +| Columna | Tipo | Descripción |
340 +|---------|------|-------------|
341 +| objeto | INTEGER | Código del objeto |
342 +| nivel | VARCHAR(20) | Nivel del objeto |
343 +| entidad | INTEGER | Código de la entidad |
344 +| gestion | INTEGER | Año fiscal |
345 +| devengado | NUMERIC | Monto devengado |
346 +
347 +**Índices recomendados:**
348 +```sql
349 +CREATE INDEX idx_vista_objeto_entidad_objeto ON ppto.vista_objeto_entidad(objeto);
350 +CREATE INDEX idx_vista_objeto_entidad_objeto_entidad ON ppto.vista_objeto_entidad(objeto, entidad);
351 +CREATE INDEX idx_vista_objeto_entidad_objeto_nivel_entidad ON ppto.vista_objeto_entidad(objeto, nivel, entidad);
352 +```
353 +
354 +---
355 +
356 +## Jerarquías
357 +
358 +### Objetos de Gasto (4 niveles)
359 +```
360 +Grupo (1xxxx) → Subgrupo (11xxx) → Partida (111xx) → Subpartida (11110)
361 +```
362 +
363 +### Rubros de Ingresos (4 niveles)
364 +```
365 +Tipo → Clase → Cuenta → Subcuenta
366 +```
367 +
368 +### Finalidad y Función (3 niveles)
369 +```
370 +Finalidad (X) → Grupo Función (XX) → Función (XXX)
371 +```
372 +
373 +### Institucional (5 niveles)
374 +```
375 +Sector → Subsector → Área → Subárea → Entidad
376 +```
377 +
378 +---
379 +
380 +
381 +
382 +*Documento generado para el equipo de sistemas - Marzo 2026*