Rafael Lopez

general

...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
9 "version": "0.0.1", 9 "version": "0.0.1",
10 "dependencies": { 10 "dependencies": {
11 "@observablehq/plot": "^0.6.17", 11 "@observablehq/plot": "^0.6.17",
12 - "@supabase/supabase-js": "^2.98.0",
13 "@sveltejs/adapter-node": "^5.5.4", 12 "@sveltejs/adapter-node": "^5.5.4",
14 "d3": "^7.9.0", 13 "d3": "^7.9.0",
15 "fuse.js": "^7.1.0", 14 "fuse.js": "^7.1.0",
...@@ -939,86 +938,6 @@ ...@@ -939,86 +938,6 @@
939 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 938 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
940 "license": "MIT" 939 "license": "MIT"
941 }, 940 },
942 - "node_modules/@supabase/auth-js": {
943 - "version": "2.98.0",
944 - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.98.0.tgz",
945 - "integrity": "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==",
946 - "license": "MIT",
947 - "dependencies": {
948 - "tslib": "2.8.1"
949 - },
950 - "engines": {
951 - "node": ">=20.0.0"
952 - }
953 - },
954 - "node_modules/@supabase/functions-js": {
955 - "version": "2.98.0",
956 - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.98.0.tgz",
957 - "integrity": "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==",
958 - "license": "MIT",
959 - "dependencies": {
960 - "tslib": "2.8.1"
961 - },
962 - "engines": {
963 - "node": ">=20.0.0"
964 - }
965 - },
966 - "node_modules/@supabase/postgrest-js": {
967 - "version": "2.98.0",
968 - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.98.0.tgz",
969 - "integrity": "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==",
970 - "license": "MIT",
971 - "dependencies": {
972 - "tslib": "2.8.1"
973 - },
974 - "engines": {
975 - "node": ">=20.0.0"
976 - }
977 - },
978 - "node_modules/@supabase/realtime-js": {
979 - "version": "2.98.0",
980 - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.98.0.tgz",
981 - "integrity": "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==",
982 - "license": "MIT",
983 - "dependencies": {
984 - "@types/phoenix": "^1.6.6",
985 - "@types/ws": "^8.18.1",
986 - "tslib": "2.8.1",
987 - "ws": "^8.18.2"
988 - },
989 - "engines": {
990 - "node": ">=20.0.0"
991 - }
992 - },
993 - "node_modules/@supabase/storage-js": {
994 - "version": "2.98.0",
995 - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.98.0.tgz",
996 - "integrity": "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==",
997 - "license": "MIT",
998 - "dependencies": {
999 - "iceberg-js": "^0.8.1",
1000 - "tslib": "2.8.1"
1001 - },
1002 - "engines": {
1003 - "node": ">=20.0.0"
1004 - }
1005 - },
1006 - "node_modules/@supabase/supabase-js": {
1007 - "version": "2.98.0",
1008 - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.98.0.tgz",
1009 - "integrity": "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==",
1010 - "license": "MIT",
1011 - "dependencies": {
1012 - "@supabase/auth-js": "2.98.0",
1013 - "@supabase/functions-js": "2.98.0",
1014 - "@supabase/postgrest-js": "2.98.0",
1015 - "@supabase/realtime-js": "2.98.0",
1016 - "@supabase/storage-js": "2.98.0"
1017 - },
1018 - "engines": {
1019 - "node": ">=20.0.0"
1020 - }
1021 - },
1022 "node_modules/@sveltejs/acorn-typescript": { 941 "node_modules/@sveltejs/acorn-typescript": {
1023 "version": "1.0.9", 942 "version": "1.0.9",
1024 "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", 943 "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
...@@ -1415,21 +1334,6 @@ ...@@ -1415,21 +1334,6 @@
1415 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 1334 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1416 "license": "MIT" 1335 "license": "MIT"
1417 }, 1336 },
1418 - "node_modules/@types/node": {
1419 - "version": "25.3.2",
1420 - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz",
1421 - "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
1422 - "license": "MIT",
1423 - "dependencies": {
1424 - "undici-types": "~7.18.0"
1425 - }
1426 - },
1427 - "node_modules/@types/phoenix": {
1428 - "version": "1.6.7",
1429 - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
1430 - "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
1431 - "license": "MIT"
1432 - },
1433 "node_modules/@types/resolve": { 1337 "node_modules/@types/resolve": {
1434 "version": "1.20.2", 1338 "version": "1.20.2",
1435 "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", 1339 "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
...@@ -1442,15 +1346,6 @@ ...@@ -1442,15 +1346,6 @@
1442 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 1346 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
1443 "license": "MIT" 1347 "license": "MIT"
1444 }, 1348 },
1445 - "node_modules/@types/ws": {
1446 - "version": "8.18.1",
1447 - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
1448 - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
1449 - "license": "MIT",
1450 - "dependencies": {
1451 - "@types/node": "*"
1452 - }
1453 - },
1454 "node_modules/acorn": { 1349 "node_modules/acorn": {
1455 "version": "8.16.0", 1350 "version": "8.16.0",
1456 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", 1351 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
...@@ -2114,15 +2009,6 @@ ...@@ -2114,15 +2009,6 @@
2114 "integrity": "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==", 2009 "integrity": "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==",
2115 "license": "MIT" 2010 "license": "MIT"
2116 }, 2011 },
2117 - "node_modules/iceberg-js": {
2118 - "version": "0.8.1",
2119 - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
2120 - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
2121 - "license": "MIT",
2122 - "engines": {
2123 - "node": ">=20.0.0"
2124 - }
2125 - },
2126 "node_modules/iconv-lite": { 2012 "node_modules/iconv-lite": {
2127 "version": "0.6.3", 2013 "version": "0.6.3",
2128 "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 2014 "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
...@@ -2775,13 +2661,9 @@ ...@@ -2775,13 +2661,9 @@
2775 "version": "2.8.1", 2661 "version": "2.8.1",
2776 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 2662 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2777 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 2663 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2778 - "license": "0BSD" 2664 + "dev": true,
2779 - }, 2665 + "license": "0BSD",
2780 - "node_modules/undici-types": { 2666 + "optional": true
2781 - "version": "7.18.2",
2782 - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
2783 - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
2784 - "license": "MIT"
2785 }, 2667 },
2786 "node_modules/vite": { 2668 "node_modules/vite": {
2787 "version": "7.3.1", 2669 "version": "7.3.1",
...@@ -2876,27 +2758,6 @@ ...@@ -2876,27 +2758,6 @@
2876 } 2758 }
2877 } 2759 }
2878 }, 2760 },
2879 - "node_modules/ws": {
2880 - "version": "8.19.0",
2881 - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
2882 - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
2883 - "license": "MIT",
2884 - "engines": {
2885 - "node": ">=10.0.0"
2886 - },
2887 - "peerDependencies": {
2888 - "bufferutil": "^4.0.1",
2889 - "utf-8-validate": ">=5.0.2"
2890 - },
2891 - "peerDependenciesMeta": {
2892 - "bufferutil": {
2893 - "optional": true
2894 - },
2895 - "utf-8-validate": {
2896 - "optional": true
2897 - }
2898 - }
2899 - },
2900 "node_modules/zimmerframe": { 2761 "node_modules/zimmerframe": {
2901 "version": "1.1.4", 2762 "version": "1.1.4",
2902 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", 2763 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
......
...@@ -20,7 +20,6 @@ ...@@ -20,7 +20,6 @@
20 }, 20 },
21 "dependencies": { 21 "dependencies": {
22 "@observablehq/plot": "^0.6.17", 22 "@observablehq/plot": "^0.6.17",
23 - "@supabase/supabase-js": "^2.98.0",
24 "@sveltejs/adapter-node": "^5.5.4", 23 "@sveltejs/adapter-node": "^5.5.4",
25 "d3": "^7.9.0", 24 "d3": "^7.9.0",
26 "fuse.js": "^7.1.0", 25 "fuse.js": "^7.1.0",
......
1 -import Fuse from 'fuse.js';
2 -import { supabase } from '$lib/supabase';
3 -import { normalizeText } from '$lib/utils/normalize';
4 -
5 -let fuseInstance = null;
6 -let searchData = [];
7 -
8 -/**
9 - * Configuración de Fuse.js
10 - */
11 -const fuseOptions = {
12 - keys: [
13 - { name: 'nombre_normalizado', weight: 0.6 },
14 - { name: 'sigla_normalizada', weight: 0.2 },
15 - { name: 'codigo_normalizado', weight: 0.2 }
16 - ],
17 - threshold: 0.4, // 0 = exacto, 1 = muy fuzzy
18 - distance: 100,
19 - includeScore: true,
20 - includeMatches: true,
21 - minMatchCharLength: 2,
22 - ignoreLocation: true
23 -};
24 -
25 -/**
26 - * Carga las entidades y objetos de gasto desde Supabase y construye el índice
27 - */
28 -export async function initSearchIndex() {
29 - if (fuseInstance) return fuseInstance;
30 -
31 - // Cargar entidades
32 - const { data: entidades, error: errorEntidades } = await supabase
33 - .schema('ppto')
34 - .from('clas_institucional')
35 - .select('entidad, desc_entidad, sigla_entidad, desc_area, n_gestiones');
36 -
37 - if (errorEntidades) {
38 - console.error('Error cargando entidades:', errorEntidades);
39 - throw errorEntidades;
40 - }
41 -
42 - // Cargar objetos de gasto
43 - const { data: objetos, error: errorObjetos } = await supabase
44 - .schema('ppto')
45 - .from('clas_objetos')
46 - .select('objeto, desc_objeto, nivel');
47 -
48 - if (errorObjetos) {
49 - console.error('Error cargando objetos:', errorObjetos);
50 - throw errorObjetos;
51 - }
52 -
53 - // Cargar rubros
54 - const { data: rubros, error: errorRubros } = await supabase
55 - .schema('ppto')
56 - .from('clas_rubros')
57 - .select('rubro, desc_rubro, nivel');
58 -
59 - if (errorRubros) {
60 - console.error('Error cargando rubros:', errorRubros);
61 - throw errorRubros;
62 - }
63 -
64 - // Cargar finalidad y función
65 - const { data: finfun, error: errorFinfun } = await supabase
66 - .schema('ppto')
67 - .from('clas_finfun')
68 - .select('finfun, desc_finfun, nivel');
69 -
70 - if (errorFinfun) {
71 - console.error('Error cargando finalidad/función:', errorFinfun);
72 - throw errorFinfun;
73 - }
74 -
75 - // Cargar organismos (no bloquea si falla)
76 - let organismos = [];
77 - const { data: organismosData, error: errorOrganismos } = await supabase
78 - .schema('ppto')
79 - .from('clas_organismos')
80 - .select('organismo, organismo_desc_organismo, organismo_grupo, organismo_sub_grupo');
81 -
82 - if (errorOrganismos) {
83 - console.warn('Tabla clas_organismos no disponible:', errorOrganismos.message);
84 - } else {
85 - organismos = organismosData || [];
86 - }
87 -
88 - // Cargar fuentes (no bloquea si falla)
89 - let fuentes = [];
90 - const { data: fuentesData, error: errorFuentes } = await supabase
91 - .schema('ppto')
92 - .from('clas_fuentes')
93 - .select('fuente, desc_fuente, grupo_fuente, desc_grupo_fuente');
94 -
95 - if (errorFuentes) {
96 - console.warn('Tabla clas_fuentes no disponible:', errorFuentes.message);
97 - } else {
98 - fuentes = fuentesData || [];
99 - }
100 -
101 - // Transformar entidades para el índice
102 - const entidadesIndex = entidades.map(item => ({
103 - tipo: 'entidad',
104 - codigo: item.entidad,
105 - nombre: item.desc_entidad,
106 - sigla: item.sigla_entidad,
107 - contexto: item.desc_area,
108 - años: item.n_gestiones,
109 - nombre_normalizado: normalizeText(item.desc_entidad),
110 - sigla_normalizada: normalizeText(item.sigla_entidad),
111 - codigo_normalizado: item.entidad?.toString() || ''
112 - }));
113 -
114 - // Transformar objetos de gasto para el índice
115 - const nivelLabelsObjetos = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
116 - const objetosIndex = objetos.map(item => ({
117 - tipo: 'objeto_gasto',
118 - codigo: item.objeto,
119 - nombre: item.desc_objeto,
120 - sigla: null,
121 - contexto: `Objeto del Gasto · ${nivelLabelsObjetos[item.nivel] || item.nivel}`,
122 - nivel: item.nivel,
123 - nombre_normalizado: normalizeText(item.desc_objeto),
124 - sigla_normalizada: '',
125 - codigo_normalizado: item.objeto || ''
126 - }));
127 -
128 - // Transformar rubros para el índice
129 - const nivelLabelsRubros = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
130 - const rubrosIndex = rubros.map(item => ({
131 - tipo: 'rubro',
132 - codigo: item.rubro,
133 - nombre: item.desc_rubro,
134 - sigla: null,
135 - contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`,
136 - nivel: item.nivel,
137 - nombre_normalizado: normalizeText(item.desc_rubro),
138 - sigla_normalizada: '',
139 - codigo_normalizado: item.rubro?.toString() || ''
140 - }));
141 -
142 - // Transformar finalidad y función para el índice
143 - const nivelLabelsFinfun = { finalidad: 'Finalidad', grpfuncion: 'Grupo Función', funcion: 'Función' };
144 - const finfunIndex = finfun.map(item => ({
145 - tipo: 'finfun',
146 - codigo: item.finfun,
147 - nombre: item.desc_finfun,
148 - sigla: null,
149 - contexto: `Finalidad y Función · ${nivelLabelsFinfun[item.nivel] || item.nivel}`,
150 - nivel: item.nivel,
151 - nombre_normalizado: normalizeText(item.desc_finfun),
152 - sigla_normalizada: '',
153 - codigo_normalizado: item.finfun?.toString() || ''
154 - }));
155 -
156 - // Transformar organismos para el índice
157 - // Todos los registros en la tabla son organismos (nivel hoja)
158 - const organismosIndex = organismos.map(item => ({
159 - tipo: 'organismo',
160 - codigo: item.organismo,
161 - nombre: item.organismo_desc_organismo,
162 - sigla: null,
163 - contexto: `Organismo · Grupo ${item.organismo_grupo}`,
164 - nivel: 'organismo',
165 - nombre_normalizado: normalizeText(item.organismo_desc_organismo),
166 - sigla_normalizada: '',
167 - codigo_normalizado: item.organismo?.toString() || ''
168 - }));
169 -
170 - // Transformar fuentes para el índice
171 - const fuentesIndex = fuentes.map(item => ({
172 - tipo: 'fuente',
173 - codigo: item.fuente,
174 - nombre: item.desc_fuente,
175 - sigla: null,
176 - contexto: `Fuente · ${item.desc_grupo_fuente}`,
177 - nivel: 'fuente',
178 - nombre_normalizado: normalizeText(item.desc_fuente),
179 - sigla_normalizada: '',
180 - codigo_normalizado: item.fuente?.toString() || ''
181 - }));
182 -
183 - // Combinar todos los conjuntos
184 - searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex, ...finfunIndex, ...organismosIndex, ...fuentesIndex];
185 -
186 - fuseInstance = new Fuse(searchData, fuseOptions);
187 -
188 - return fuseInstance;
189 -}
190 -
191 -/**
192 - * Busca en el índice
193 - * @param {string} query - Texto de búsqueda
194 - * @param {number} limit - Máximo de resultados (default 30)
195 - * @returns {Array} Resultados de búsqueda
196 - */
197 -export function search(query, limit = 30) {
198 - if (!fuseInstance) {
199 - console.warn('Índice no inicializado. Llama initSearchIndex() primero.');
200 - return [];
201 - }
202 -
203 - if (!query || query.length < 2) {
204 - return [];
205 - }
206 -
207 - const normalizedQuery = normalizeText(query);
208 - const results = fuseInstance.search(normalizedQuery, { limit });
209 -
210 - return results.map(result => ({
211 - ...result.item,
212 - score: result.score,
213 - matches: result.matches
214 - }));
215 -}
216 -
217 -/**
218 - * Obtiene todas las entidades (sin búsqueda)
219 - */
220 -export function getAllEntidades() {
221 - return searchData.filter(item => item.tipo === 'entidad');
222 -}
223 -
224 -/**
225 - * Obtiene todos los objetos de gasto (sin búsqueda)
226 - */
227 -export function getAllObjetos() {
228 - return searchData.filter(item => item.tipo === 'objeto_gasto');
229 -}
230 -
231 -/**
232 - * Verifica si el índice está cargado
233 - */
234 -export function isIndexReady() {
235 - return fuseInstance !== null;
236 -}
1 -import { writable, derived, get } from 'svelte/store'; 1 +import { writable, derived } from 'svelte/store';
2 -import { initSearchIndex, search, isIndexReady } from '$lib/services/search';
3 2
4 -// Estado del buscador
5 export const query = writable(''); 3 export const query = writable('');
6 export const results = writable([]); 4 export const results = writable([]);
7 export const isLoading = writable(false); 5 export const isLoading = writable(false);
8 -export const indexLoaded = writable(false); 6 +export const indexLoaded = writable(true);
9 export const error = writable(null); 7 export const error = writable(null);
10 export const selectedIndex = writable(-1); 8 export const selectedIndex = writable(-1);
11 -export const filterType = writable('todos'); // todos, entidad, objeto_gasto, rubro, finfun 9 +export const filterType = writable('todos');
12 10
13 -// Estado derivado: si está mostrando resultados
14 export const showResults = derived( 11 export const showResults = derived(
15 [query, results, indexLoaded], 12 [query, results, indexLoaded],
16 ([$query, $results, $indexLoaded]) => $indexLoaded && $query.length >= 2 13 ([$query, $results, $indexLoaded]) => $indexLoaded && $query.length >= 2
17 ); 14 );
18 15
19 -/**
20 - * Inicializa el índice de búsqueda
21 - */
22 export async function initIndex() { 16 export async function initIndex() {
23 - if (isIndexReady()) {
24 indexLoaded.set(true); 17 indexLoaded.set(true);
25 - return;
26 - }
27 -
28 - isLoading.set(true);
29 - error.set(null);
30 -
31 - try {
32 - await initSearchIndex();
33 - indexLoaded.set(true);
34 - } catch (e) {
35 - error.set('Error al cargar el índice de búsqueda');
36 - console.error(e);
37 - } finally {
38 - isLoading.set(false);
39 - }
40 } 18 }
41 19
42 -/** 20 +export function performSearch() {}
43 - * Realiza la búsqueda
44 - */
45 -export function performSearch(searchQuery, filter = null) {
46 - query.set(searchQuery);
47 - selectedIndex.set(-1);
48 -
49 - if (searchQuery.length < 2) {
50 - results.set([]);
51 - return;
52 - }
53 -
54 - let searchResults = search(searchQuery, 30);
55 -
56 - // Filtrar por tipo si no es "todos"
57 - const currentFilter = filter !== null ? filter : get(filterType);
58 - if (currentFilter !== 'todos') {
59 - searchResults = searchResults.filter(r => r.tipo === currentFilter);
60 - }
61 -
62 - // Limitar resultados
63 - results.set(searchResults.slice(0, 15));
64 -}
65 -
66 -/**
67 - * Limpia la búsqueda
68 - */
69 export function clearSearch() { 21 export function clearSearch() {
70 query.set(''); 22 query.set('');
71 results.set([]); 23 results.set([]);
72 selectedIndex.set(-1); 24 selectedIndex.set(-1);
73 } 25 }
74 - 26 +export function setFilter() {}
75 -/** 27 +export function navigateResults() {}
76 - * Cambia el filtro de tipo
77 - */
78 -export function setFilter(type) {
79 - filterType.set(type);
80 - // Re-ejecutar búsqueda con el nuevo filtro
81 - const currentQuery = get(query);
82 - if (currentQuery.length >= 2) {
83 - performSearch(currentQuery, type);
84 - }
85 -}
86 -
87 -/**
88 - * Navegación por teclado
89 - */
90 -export function navigateResults(direction, totalResults) {
91 - selectedIndex.update(current => {
92 - if (direction === 'down') {
93 - return current < totalResults - 1 ? current + 1 : 0;
94 - } else if (direction === 'up') {
95 - return current > 0 ? current - 1 : totalResults - 1;
96 - }
97 - return current;
98 - });
99 -}
......
1 -import { createClient } from '@supabase/supabase-js';
2 -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
3 -
4 -export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
1 -import { supabase } from '$lib/supabase';
2 -import { json } from '@sveltejs/kit';
3 -
4 -export async function GET() {
5 - const { data, error } = await supabase
6 - .schema('ppto')
7 - .from('clas_institucional')
8 - .select('*')
9 - .limit(5);
10 -
11 - if (error) {
12 - return json({ error: error.message }, { status: 500 });
13 - }
14 -
15 - return json({
16 - success: true,
17 - count: data.length,
18 - columns: data.length > 0 ? Object.keys(data[0]) : [],
19 - sample: data
20 - });
21 -}
1 -<script>
2 - import { supabase } from '$lib/supabase';
3 - import { onMount } from 'svelte';
4 - import { fade, scale } from 'svelte/transition';
5 - import { cubicOut } from 'svelte/easing';
6 -
7 - let loading = $state(true);
8 - let searchQuery = $state('');
9 - let grupos = $state([]);
10 - let allItems = $state([]);
11 - let selectedGrupo = $state(null);
12 - let selectedItem = $state(null);
13 - let highlightedItem = $state(null);
14 - let sidebarOpen = $state(false);
15 -
16 - onMount(async () => {
17 - // Forzar recálculo de layout para navegación cliente
18 - requestAnimationFrame(() => {
19 - document.body.offsetHeight; // Force reflow
20 - });
21 -
22 - const { data, error } = await supabase
23 - .schema('ppto')
24 - .from('clas_fuentes')
25 - .select('*')
26 - .order('fuente');
27 -
28 - if (!error && data) {
29 - allItems = data;
30 -
31 - // Crear grupos sintéticos desde valores únicos de grupo_fuente
32 - const gruposUnicos = [...new Set(data.map(item => item.grupo_fuente))].filter(g => g != null);
33 - grupos = gruposUnicos.map(grupoNum => {
34 - const sample = data.find(item => item.grupo_fuente === grupoNum);
35 - return {
36 - grupo_fuente: grupoNum,
37 - desc_grupo_fuente: sample?.desc_grupo_fuente || `Grupo ${grupoNum}`
38 - };
39 - }).sort((a, b) => Number(a.grupo_fuente) - Number(b.grupo_fuente));
40 -
41 - if (grupos.length > 0) {
42 - selectedGrupo = grupos[0];
43 - }
44 - }
45 -
46 - loading = false;
47 - });
48 -
49 - // Obtener fuentes para un grupo específico
50 - function getFuentesForGrupo(grupo) {
51 - if (!grupo) return [];
52 - return allItems
53 - .filter(item => item.grupo_fuente === grupo.grupo_fuente)
54 - .sort((a, b) => String(a.fuente).localeCompare(String(b.fuente)));
55 - }
56 -
57 - // Búsqueda global
58 - function searchGlobal(query) {
59 - if (!query || query.length < 2) return [];
60 - const q = query.toLowerCase();
61 -
62 - return allItems
63 - .filter(item =>
64 - item.fuente?.toString().includes(q) ||
65 - item.desc_fuente?.toLowerCase().includes(q) ||
66 - item.desc_grupo_fuente?.toLowerCase().includes(q)
67 - )
68 - .slice(0, 20);
69 - }
70 -
71 - function parseDescripciones(descripcionesStr) {
72 - if (!descripcionesStr) return [];
73 - try {
74 - const parsed = typeof descripcionesStr === 'string'
75 - ? JSON.parse(descripcionesStr)
76 - : descripcionesStr;
77 - return parsed.sort((a, b) => {
78 - const maxYearA = getMaxYear(a.rangos);
79 - const maxYearB = getMaxYear(b.rangos);
80 - return maxYearB - maxYearA;
81 - });
82 - } catch {
83 - return [];
84 - }
85 - }
86 -
87 - function getMaxYear(rangos) {
88 - if (!rangos) return 0;
89 - const years = rangos.match(/\d{4}/g);
90 - if (!years) return 0;
91 - return Math.max(...years.map(y => parseInt(y)));
92 - }
93 -
94 - function selectGrupo(grupo) {
95 - selectedGrupo = grupo;
96 - selectedItem = null;
97 - searchQuery = '';
98 - highlightedItem = null;
99 - sidebarOpen = false;
100 - }
101 -
102 - function openDetail(item) {
103 - selectedItem = item;
104 - }
105 -
106 - function closeDetail() {
107 - selectedItem = null;
108 - }
109 -
110 - function goToSearchResult(item) {
111 - const targetGrupo = grupos.find(g => g.grupo_fuente === item.grupo_fuente);
112 -
113 - if (targetGrupo) {
114 - selectedGrupo = targetGrupo;
115 - highlightedItem = item.fuente;
116 - searchQuery = '';
117 - sidebarOpen = false;
118 -
119 - setTimeout(() => {
120 - const element = document.getElementById(`f-${item.fuente}`);
121 - if (element) {
122 - element.scrollIntoView({ behavior: 'smooth', block: 'center' });
123 - }
124 - }, 100);
125 -
126 - setTimeout(() => {
127 - highlightedItem = null;
128 - }, 2000);
129 - }
130 - }
131 -
132 - let grupoContent = $derived(selectedGrupo ? getFuentesForGrupo(selectedGrupo) : []);
133 - let searchResults = $derived(searchGlobal(searchQuery));
134 - let isSearching = $derived(searchQuery.length >= 2);
135 -
136 - // Contadores
137 - let totalItems = $derived(allItems.length);
138 - let totalGrupos = $derived(grupos.length);
139 -</script>
140 -
141 -<svelte:head>
142 - <title>Fuentes de Financiamiento | Presupuesto Público</title>
143 - <link rel="preconnect" href="https://fonts.googleapis.com" />
144 - <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
145 -</svelte:head>
146 -
147 -<div class="min-h-screen" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo); transition: background-color 0.2s, color 0.2s;">
148 - <!-- Header pedagógico -->
149 - <header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
150 - <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
151 - <!-- Breadcrumb: responsive -->
152 - <nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
153 - <!-- Móvil: solo padre -->
154 - <a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
155 - ← Clasificadores
156 - </a>
157 - <!-- Desktop: ruta completa -->
158 - <div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
159 - <a href="/" class="transition-colors hover:opacity-80">Inicio</a>
160 - <span style="opacity: 0.5;">/</span>
161 - <a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
162 - </div>
163 - </nav>
164 -
165 - <div class="max-w-3xl">
166 - <p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
167 - Clasificador 02
168 - </p>
169 - <h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
170 - ¿Con qué recursos se financia?
171 - </h1>
172 -
173 - <p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
174 - Organiza los recursos según su <strong style="color: var(--theme-titulo);">origen de financiamiento</strong>:
175 - Tesoro General, recursos específicos, créditos externos e internos, donaciones.
176 - Permite entender de dónde provienen los fondos para el gasto público.
177 - </p>
178 -
179 - {#if totalItems > 0}
180 - <p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
181 - <span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> fuentes de financiamiento en
182 - <span style="color: var(--theme-titulo); font-weight: 500;">{totalGrupos}</span> grupos
183 - </p>
184 - {/if}
185 -
186 - <!-- Jerarquía -->
187 - <div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
188 - <span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
189 - Grupo
190 - </span>
191 - <span style="color: var(--theme-texto);">→</span>
192 - <span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
193 - Fuente
194 - </span>
195 - </div>
196 - <!-- Versión móvil simplificada -->
197 - <p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
198 - Jerarquía: Grupo → Fuente
199 - </p>
200 - </div>
201 - </div>
202 - </header>
203 -
204 - {#if loading}
205 - <div class="flex items-center justify-center py-20">
206 - <p style="color: var(--theme-texto);">Cargando clasificador...</p>
207 - </div>
208 - {:else}
209 - <!-- Botón móvil para abrir sidebar -->
210 - <div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
211 - <button
212 - onclick={() => sidebarOpen = !sidebarOpen}
213 - class="flex items-center gap-2 text-sm"
214 - style="color: var(--theme-texto);"
215 - >
216 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
217 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
218 - </svg>
219 - <span class="font-medium">{selectedGrupo ? selectedGrupo.desc_grupo_fuente : 'Seleccionar grupo'}</span>
220 - <svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
221 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
222 - </svg>
223 - </button>
224 - </div>
225 -
226 - <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
227 - <!-- Sidebar izquierda: Grupos -->
228 - {#if sidebarOpen}
229 - <div
230 - transition:fade={{ duration: 150 }}
231 - class="fixed inset-0 bg-black/30 z-40 lg:hidden"
232 - onclick={() => sidebarOpen = false}
233 - ></div>
234 - {/if}
235 - <aside class="
236 - {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
237 - lg:translate-x-0
238 - fixed lg:relative
239 - inset-y-0 left-0
240 - w-72 lg:w-64
241 - z-50 lg:z-auto
242 - transition-transform duration-200 ease-in-out
243 - lg:flex-shrink-0
244 - shadow-xl lg:shadow-none
245 - sidebar-left
246 - ">
247 - <div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-6">
248 - <!-- Cerrar en móvil -->
249 - <div class="flex justify-between items-center mb-4 lg:hidden">
250 - <span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de fuentes</span>
251 - <button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
252 - <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
253 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
254 - </svg>
255 - </button>
256 - </div>
257 -
258 - <!-- Buscador -->
259 - <div class="mb-6">
260 - <input
261 - type="text"
262 - bind:value={searchQuery}
263 - placeholder="Buscar..."
264 - class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
265 - style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
266 - />
267 - </div>
268 -
269 - <!-- Resultados de búsqueda -->
270 - {#if isSearching}
271 - <div class="mb-4">
272 - <p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
273 - {searchResults.length} resultados
274 - </p>
275 - <div class="space-y-1">
276 - {#each searchResults as result}
277 - <button
278 - class="w-full text-left px-2 py-2 text-sm rounded transition-all"
279 - style="color: var(--theme-titulo);"
280 - onclick={() => goToSearchResult(result)}
281 - >
282 - <span class="text-xs block" style="color: var(--theme-texto);">Fuente</span>
283 - <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.fuente}</span>
284 - <span class="ml-1">{result.desc_fuente}</span>
285 - </button>
286 - {/each}
287 - {#if searchResults.length === 0}
288 - <p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
289 - {/if}
290 - </div>
291 - </div>
292 - {:else}
293 - <!-- Lista de grupos -->
294 - <nav>
295 - <p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
296 - <ul class="space-y-1">
297 - {#each grupos as grupo}
298 - <li>
299 - <button
300 - class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
301 - style="{selectedGrupo?.grupo_fuente === grupo.grupo_fuente
302 - ? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 2px solid var(--theme-accent);`
303 - : `color: var(--theme-texto);`}"
304 - onclick={() => selectGrupo(grupo)}
305 - >
306 - <span class="font-mono text-xs block" style="color: var(--theme-texto);">Grupo {grupo.grupo_fuente}</span>
307 - {grupo.desc_grupo_fuente}
308 - </button>
309 - </li>
310 - {/each}
311 - </ul>
312 - </nav>
313 - {/if}
314 - </div>
315 - </aside>
316 -
317 - <!-- Contenido principal -->
318 - <main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
319 - <div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
320 - {#if selectedGrupo}
321 - {#key selectedGrupo.grupo_fuente}
322 - <div in:fade={{ duration: 200, delay: 50 }}>
323 - <!-- Título del grupo -->
324 - <div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
325 - <p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">Grupo {selectedGrupo.grupo_fuente}</p>
326 - <h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
327 - {selectedGrupo.desc_grupo_fuente}
328 - </h2>
329 - <p class="text-sm" style="color: var(--theme-texto);">
330 - {grupoContent.length} fuentes de financiamiento en este grupo
331 - </p>
332 - </div>
333 -
334 - <!-- Fuentes -->
335 - <div class="space-y-8">
336 - {#each grupoContent as fuente}
337 - {@const fuenteDescs = parseDescripciones(fuente.descripciones)}
338 - <section
339 - id="f-{fuente.fuente}"
340 - class="scroll-mt-4 {highlightedItem === fuente.fuente ? 'highlighted' : ''}"
341 - >
342 - <div class="flex items-start gap-2 sm:gap-4 mb-3">
343 - <span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{fuente.fuente}</span>
344 - <div class="flex-1">
345 - <h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
346 - <span class="text-left">{fuente.desc_fuente}</span>
347 - {#if fuente.n_variaciones > 1}
348 - <button
349 - class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
350 - onclick={() => openDetail(fuente)}
351 - title="Ver variaciones de descripción"
352 - >
353 - {fuente.n_variaciones} var.
354 - </button>
355 - {/if}
356 - <a
357 - href="/fuente/{fuente.fuente}"
358 - class="transition-colors hover:text-[var(--theme-accent)]"
359 - style="color: var(--theme-texto);"
360 - title="Ver página de detalle"
361 - >
362 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
363 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
364 - </svg>
365 - </a>
366 - </h3>
367 - {#if fuenteDescs.length > 0}
368 - <p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{fuenteDescs[0].descripcion}</p>
369 - {/if}
370 - <!-- Vigencia temporal -->
371 - {#if fuente.gestiones}
372 - <p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
373 - Vigente: {fuente.gestiones}
374 - </p>
375 - {/if}
376 - </div>
377 - </div>
378 - </section>
379 - {/each}
380 - </div>
381 - </div>
382 - {/key}
383 - {/if}
384 - </div>
385 - </main>
386 -
387 - <!-- Sidebar derecha: En esta página -->
388 - <aside class="w-56 flex-shrink-0 hidden xl:block">
389 - <div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
390 - <p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
391 - {#if selectedGrupo && !isSearching}
392 - <nav class="space-y-2">
393 - {#each grupoContent as fuente}
394 - <a
395 - href="#f-{fuente.fuente}"
396 - class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
397 - style="color: var(--theme-texto);"
398 - title="{fuente.desc_fuente}"
399 - >
400 - <span class="font-mono text-xs" style="opacity: 0.6;">{fuente.fuente}</span>
401 - {fuente.desc_fuente}
402 - </a>
403 - {/each}
404 - </nav>
405 - {/if}
406 - </div>
407 - </aside>
408 - </div>
409 - {/if}
410 -</div>
411 -
412 -<!-- Modal de detalle -->
413 -{#if selectedItem}
414 - <div
415 - transition:fade={{ duration: 150 }}
416 - class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
417 - onclick={closeDetail}
418 - >
419 - <div
420 - transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
421 - class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
422 - style="background-color: var(--theme-surface);"
423 - onclick={(e) => e.stopPropagation()}
424 - >
425 - <div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
426 - <div>
427 - <p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">Fuente de Financiamiento</p>
428 - <h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
429 - <span class="font-mono" style="color: var(--theme-texto);">{selectedItem.fuente}</span>
430 - <span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
431 - {selectedItem.desc_fuente}
432 - </h3>
433 - </div>
434 - <button
435 - onclick={closeDetail}
436 - class="text-2xl leading-none"
437 - style="color: var(--theme-texto);"
438 - >
439 - &times;
440 - </button>
441 - </div>
442 -
443 - <div class="p-6 overflow-y-auto flex-1">
444 - <h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
445 - {#if selectedItem.n_variaciones > 1}
446 - Descripciones ({selectedItem.n_variaciones} variaciones)
447 - {:else}
448 - Descripción
449 - {/if}
450 - </h4>
451 -
452 - <div class="space-y-4">
453 - {#each parseDescripciones(selectedItem.descripciones) as desc, i}
454 - <div class="border-l-2 pl-4 py-3 rounded-r" style="{i === 0 ? `border-color: var(--theme-accent); background-color: color-mix(in srgb, var(--theme-accent) 10%, transparent);` : `border-color: var(--theme-borde);`}">
455 - <p class="text-sm mb-2" style="color: var(--theme-texto);">
456 - {#if i === 0 && selectedItem.n_variaciones > 1}
457 - <span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
458 - <span class="mx-1" style="opacity: 0.5;">·</span>
459 - {/if}
460 - <span class="font-mono">{desc.rangos}</span>
461 - </p>
462 - <p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
463 - </div>
464 - {/each}
465 - </div>
466 -
467 - <!-- Vigencia temporal -->
468 - {#if selectedItem.gestiones}
469 - <div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
470 - <p class="text-xs font-mono" style="color: var(--theme-texto);">
471 - <span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
472 - </p>
473 - </div>
474 - {/if}
475 - </div>
476 - </div>
477 - </div>
478 -{/if}
479 -
480 -<style>
481 - /* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
482 - .sidebar-left {
483 - background-color: var(--theme-surface);
484 - }
485 - @media (min-width: 1024px) {
486 - .sidebar-left {
487 - background-color: transparent;
488 - }
489 - }
490 -
491 - /* Highlight pulse animation para resultados de búsqueda */
492 - @keyframes highlight-pulse {
493 - 0%, 100% {
494 - box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent);
495 - }
496 - 50% {
497 - box-shadow: 0 0 0 4px color-mix(in srgb, var(--theme-accent) 50%, transparent);
498 - }
499 - }
500 -
501 - .highlighted {
502 - animation: highlight-pulse 0.8s ease-in-out 2;
503 - border-radius: 0.5rem;
504 - padding: 0.5rem;
505 - margin-left: -0.5rem;
506 - }
507 -
508 - /* Layout principal - fallback nativo */
509 - .clasificador-layout {
510 - display: flex !important;
511 - flex-direction: row !important;
512 - }
513 -
514 - /* Sidebar - fallback para responsive */
515 - .sidebar-left {
516 - position: fixed;
517 - transform: translateX(-100%);
518 - }
519 -
520 - @media (min-width: 1024px) {
521 - .sidebar-left {
522 - position: relative !important;
523 - transform: translateX(0) !important;
524 - flex-shrink: 0;
525 - width: 16rem;
526 - z-index: auto !important;
527 - box-shadow: none !important;
528 - }
529 - }
530 -
531 - @media (max-width: 1023px) {
532 - .clasificador-layout {
533 - display: block !important;
534 - }
535 - }
536 -</style>
...@@ -69,6 +69,7 @@ ...@@ -69,6 +69,7 @@
69 } 69 }
70 } catch { entityError = true; } 70 } catch { entityError = true; }
71 loadingEntities = false; 71 loadingEntities = false;
72 + console.log('[DESCARGAS] entities loaded:', entities.length);
72 }); 73 });
73 </script> 74 </script>
74 75
......
1 -import { supabase } from '$lib/supabase';
2 -import { error } from '@sveltejs/kit';
3 -
4 -export async function load({ params }) {
5 - console.time('[SERVER] Total load fuente');
6 - const { codigo } = params;
7 -
8 - console.time('[SERVER] Query clas_fuentes');
9 - const { data, error: dbError } = await supabase
10 - .schema('ppto')
11 - .from('clas_fuentes')
12 - .select('*')
13 - .eq('fuente', codigo);
14 - console.timeEnd('[SERVER] Query clas_fuentes');
15 -
16 - if (dbError || !data || data.length === 0) {
17 - throw error(404, 'Fuente no encontrada');
18 - }
19 -
20 - const fuente = data[0];
21 -
22 - // Obtener hermanos (otras fuentes del mismo grupo)
23 - let hermanos = [];
24 -
25 - console.time('[SERVER] Query hermanos');
26 - const { data: hermanosData } = await supabase
27 - .schema('ppto')
28 - .from('clas_fuentes')
29 - .select('*')
30 - .eq('grupo_fuente', fuente.grupo_fuente)
31 - .neq('fuente', codigo)
32 - .order('fuente');
33 -
34 - if (hermanosData) {
35 - hermanos = hermanosData;
36 - }
37 - console.timeEnd('[SERVER] Query hermanos');
38 -
39 - console.timeEnd('[SERVER] Total load fuente');
40 - return {
41 - fuente,
42 - hermanos
43 - };
44 -}
1 -<script>
2 - import { onMount } from 'svelte';
3 -
4 - // Datos del servidor (reactivos)
5 - let { data } = $props();
6 - let fuenteData = $derived(data.fuente);
7 - let hermanos = $derived(data.hermanos);
8 -
9 - // Estado
10 - let mounted = $state(false);
11 -
12 - onMount(() => {
13 - setTimeout(() => { mounted = true; }, 50);
14 - });
15 -
16 - function parseDescripciones(descripcionesStr) {
17 - if (!descripcionesStr) return [];
18 - try {
19 - const parsed = typeof descripcionesStr === 'string'
20 - ? JSON.parse(descripcionesStr)
21 - : descripcionesStr;
22 - return parsed.sort((a, b) => {
23 - const maxYearA = getMaxYear(a.rangos);
24 - const maxYearB = getMaxYear(b.rangos);
25 - return maxYearB - maxYearA;
26 - });
27 - } catch {
28 - return [];
29 - }
30 - }
31 -
32 - function getMaxYear(rangos) {
33 - if (!rangos) return 0;
34 - const years = rangos.match(/\d{4}/g);
35 - if (!years) return 0;
36 - return Math.max(...years.map(y => parseInt(y)));
37 - }
38 -
39 - let descripciones = $derived(parseDescripciones(fuenteData.descripciones));
40 -</script>
41 -
42 -<svelte:head>
43 - <title>{fuenteData.desc_fuente || fuenteData.fuente} | Clasificador de Fuentes</title>
44 - <link rel="preconnect" href="https://fonts.googleapis.com" />
45 - <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
46 -</svelte:head>
47 -
48 -<div class="page" class:mounted>
49 - <!-- Link a clasificadores -->
50 - <nav class="page-nav">
51 - <a href="/clasificadores/fuentes" class="back-link">
52 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
53 - <path d="M19 12H5M12 19l-7-7 7-7"/>
54 - </svg>
55 - Clasificador
56 - </a>
57 - </nav>
58 -
59 - <div class="dashboard-layout">
60 - <!-- CONTEXTO IZQUIERDO -->
61 - <aside class="dashboard-context">
62 - <div class="context-card">
63 - <h2 class="context-main-title">Contexto</h2>
64 -
65 - <!-- Info del grupo -->
66 - <div class="context-section">
67 - <h3 class="context-subtitle">Ubicación</h3>
68 - <div class="ubicacion-info">
69 - <div class="ubicacion-item">
70 - <span class="ubicacion-label">Grupo {fuenteData.grupo_fuente}</span>
71 - <span class="ubicacion-value">{fuenteData.desc_grupo_fuente}</span>
72 - </div>
73 - </div>
74 - </div>
75 -
76 - <!-- Hermanos -->
77 - {#if hermanos.length > 0}
78 - <div class="context-separator"></div>
79 - <div class="context-section">
80 - <h3 class="context-subtitle">Mismo grupo ({hermanos.length})</h3>
81 - <p class="context-hint">Otras fuentes del mismo grupo.</p>
82 - <div class="hermanos-list">
83 - {#each hermanos.slice(0, 10) as hermano}
84 - <a href="/fuente/{hermano.fuente}" class="hermano-link">
85 - <span class="hermano-code">{hermano.fuente}</span>
86 - <span class="hermano-name">{hermano.desc_fuente}</span>
87 - </a>
88 - {/each}
89 - {#if hermanos.length > 10}
90 - <span class="hermanos-more">+{hermanos.length - 10} más</span>
91 - {/if}
92 - </div>
93 - </div>
94 - {/if}
95 - </div>
96 - </aside>
97 -
98 - <!-- CONTENIDO PRINCIPAL -->
99 - <main class="dashboard-main">
100 - <div class="main-content">
101 - <!-- Header -->
102 - <div class="main-header">
103 - <div class="main-title-pills">
104 - <span class="pill pill-codigo">{fuenteData.fuente}</span>
105 - </div>
106 - <h1 class="main-title-name">{fuenteData.desc_fuente}</h1>
107 - </div>
108 -
109 - <!-- Info cards -->
110 - <div class="info-grid">
111 - <div class="info-card">
112 - <span class="info-label">Grupo</span>
113 - <span class="info-value">{fuenteData.grupo_fuente}</span>
114 - <span class="info-desc">{fuenteData.desc_grupo_fuente}</span>
115 - </div>
116 -
117 - {#if fuenteData.gestiones}
118 - <div class="info-card">
119 - <span class="info-label">Gestiones</span>
120 - <span class="info-value">{fuenteData.n_gestiones || ''} años</span>
121 - <span class="info-desc">{fuenteData.gestiones}</span>
122 - </div>
123 - {/if}
124 -
125 - {#if fuenteData.n_variaciones > 1}
126 - <div class="info-card">
127 - <span class="info-label">Variaciones</span>
128 - <span class="info-value">{fuenteData.n_variaciones}</span>
129 - <span class="info-desc">Cambios en la descripción a lo largo del tiempo</span>
130 - </div>
131 - {/if}
132 - </div>
133 -
134 - <!-- Descripciones -->
135 - {#if descripciones.length > 0}
136 - <div class="descripciones-section">
137 - <h2 class="descripciones-title">
138 - {#if descripciones.length > 1}
139 - Descripciones ({descripciones.length} variaciones)
140 - {:else}
141 - Descripción
142 - {/if}
143 - </h2>
144 - <div class="descripciones-list">
145 - {#each descripciones as desc, i}
146 - <div class="descripcion-item" class:vigente={i === 0}>
147 - <p class="descripcion-rangos">
148 - {#if i === 0 && descripciones.length > 1}
149 - <span class="vigente-badge">Vigente</span>
150 - {/if}
151 - {desc.rangos}
152 - </p>
153 - <p class="descripcion-texto">{desc.descripcion}</p>
154 - </div>
155 - {/each}
156 - </div>
157 - </div>
158 - {/if}
159 - </div>
160 - </main>
161 - </div>
162 -</div>
163 -
164 -<style>
165 - /* ═══════════════════════════════════════════════════════════════
166 - BASE
167 - ═══════════════════════════════════════════════════════════════ */
168 - .page {
169 - min-height: 100vh;
170 - padding: 1rem;
171 - opacity: 0;
172 - transform: translateY(10px);
173 - transition: opacity 0.4s ease, transform 0.4s ease;
174 - }
175 -
176 - .page.mounted {
177 - opacity: 1;
178 - transform: translateY(0);
179 - }
180 -
181 - /* ═══════════════════════════════════════════════════════════════
182 - NAV
183 - ═══════════════════════════════════════════════════════════════ */
184 - .page-nav {
185 - max-width: 1400px;
186 - margin: 0 auto 1.5rem;
187 - }
188 -
189 - .back-link {
190 - display: inline-flex;
191 - align-items: center;
192 - gap: 0.5rem;
193 - color: var(--text-secondary, #888);
194 - text-decoration: none;
195 - font-size: 0.875rem;
196 - transition: color 0.2s;
197 - }
198 -
199 - .back-link:hover {
200 - color: var(--text-primary, #fff);
201 - }
202 -
203 - /* ═══════════════════════════════════════════════════════════════
204 - LAYOUT
205 - ═══════════════════════════════════════════════════════════════ */
206 - .dashboard-layout {
207 - display: grid;
208 - grid-template-columns: 300px 1fr;
209 - gap: 2rem;
210 - max-width: 1400px;
211 - margin: 0 auto;
212 - }
213 -
214 - @media (max-width: 900px) {
215 - .dashboard-layout {
216 - grid-template-columns: 1fr;
217 - }
218 -
219 - .dashboard-context {
220 - order: 2;
221 - }
222 - }
223 -
224 - /* ═══════════════════════════════════════════════════════════════
225 - SIDEBAR
226 - ═══════════════════════════════════════════════════════════════ */
227 - .dashboard-context {
228 - position: sticky;
229 - top: 5rem;
230 - height: fit-content;
231 - }
232 -
233 - .context-card {
234 - background: var(--card-bg, rgba(255, 255, 255, 0.03));
235 - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
236 - border-radius: 16px;
237 - padding: 1.5rem;
238 - }
239 -
240 - .context-main-title {
241 - font-family: 'Instrument Sans', sans-serif;
242 - font-size: 0.75rem;
243 - font-weight: 600;
244 - text-transform: uppercase;
245 - letter-spacing: 0.05em;
246 - color: var(--text-tertiary, #666);
247 - margin-bottom: 1.5rem;
248 - }
249 -
250 - .context-section {
251 - margin-bottom: 1.5rem;
252 - }
253 -
254 - .context-subtitle {
255 - font-family: 'Instrument Sans', sans-serif;
256 - font-size: 0.8rem;
257 - font-weight: 500;
258 - color: var(--text-secondary, #aaa);
259 - margin-bottom: 0.75rem;
260 - }
261 -
262 - .context-hint {
263 - font-size: 0.75rem;
264 - color: var(--text-tertiary, #666);
265 - margin-bottom: 1rem;
266 - }
267 -
268 - .context-separator {
269 - height: 1px;
270 - background: var(--border-color, rgba(255, 255, 255, 0.08));
271 - margin: 1.5rem 0;
272 - }
273 -
274 - /* ═══════════════════════════════════════════════════════════════
275 - UBICACION INFO
276 - ═══════════════════════════════════════════════════════════════ */
277 - .ubicacion-info {
278 - display: flex;
279 - flex-direction: column;
280 - gap: 0.75rem;
281 - }
282 -
283 - .ubicacion-item {
284 - display: flex;
285 - flex-direction: column;
286 - gap: 0.25rem;
287 - padding: 0.75rem;
288 - background: var(--hover-bg, rgba(255, 255, 255, 0.03));
289 - border-radius: 8px;
290 - }
291 -
292 - .ubicacion-label {
293 - font-family: 'DM Mono', monospace;
294 - font-size: 0.7rem;
295 - color: var(--text-tertiary, #666);
296 - text-transform: uppercase;
297 - }
298 -
299 - .ubicacion-value {
300 - font-size: 0.85rem;
301 - color: var(--text-primary, #fff);
302 - }
303 -
304 - /* ═══════════════════════════════════════════════════════════════
305 - HERMANOS LIST
306 - ═══════════════════════════════════════════════════════════════ */
307 - .hermanos-list {
308 - display: flex;
309 - flex-direction: column;
310 - gap: 0.25rem;
311 - }
312 -
313 - .hermano-link {
314 - display: flex;
315 - align-items: center;
316 - gap: 0.5rem;
317 - padding: 0.5rem 0.75rem;
318 - border-radius: 6px;
319 - text-decoration: none;
320 - color: var(--text-secondary, #aaa);
321 - font-size: 0.8rem;
322 - transition: all 0.2s ease;
323 - }
324 -
325 - .hermano-link:hover {
326 - background: var(--hover-bg, rgba(255, 255, 255, 0.05));
327 - color: var(--text-primary, #fff);
328 - }
329 -
330 - .hermano-code {
331 - font-family: 'DM Mono', monospace;
332 - font-size: 0.7rem;
333 - opacity: 0.6;
334 - flex-shrink: 0;
335 - min-width: 2.5rem;
336 - }
337 -
338 - .hermano-name {
339 - white-space: nowrap;
340 - overflow: hidden;
341 - text-overflow: ellipsis;
342 - }
343 -
344 - .hermanos-more {
345 - font-size: 0.75rem;
346 - color: var(--text-tertiary, #666);
347 - padding: 0.5rem 0.75rem;
348 - }
349 -
350 - /* ═══════════════════════════════════════════════════════════════
351 - MAIN CONTENT
352 - ═══════════════════════════════════════════════════════════════ */
353 - .dashboard-main {
354 - min-width: 0;
355 - }
356 -
357 - .main-content {
358 - background: var(--card-bg, rgba(255, 255, 255, 0.03));
359 - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
360 - border-radius: 16px;
361 - padding: 2rem;
362 - }
363 -
364 - .main-header {
365 - margin-bottom: 2rem;
366 - }
367 -
368 - .main-title-pills {
369 - display: flex;
370 - gap: 0.5rem;
371 - margin-bottom: 0.75rem;
372 - }
373 -
374 - .pill {
375 - padding: 0.25rem 0.75rem;
376 - border-radius: 999px;
377 - font-size: 0.75rem;
378 - font-weight: 500;
379 - }
380 -
381 - .pill-codigo {
382 - background: var(--hover-bg, rgba(255, 255, 255, 0.05));
383 - color: var(--text-secondary, #aaa);
384 - font-family: 'DM Mono', monospace;
385 - }
386 -
387 - .main-title-name {
388 - font-family: 'DM Serif Display', serif;
389 - font-size: 2rem;
390 - font-weight: 400;
391 - color: var(--text-primary, #fff);
392 - line-height: 1.2;
393 - margin: 0;
394 - }
395 -
396 - /* ═══════════════════════════════════════════════════════════════
397 - INFO GRID
398 - ═══════════════════════════════════════════════════════════════ */
399 - .info-grid {
400 - display: grid;
401 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
402 - gap: 1rem;
403 - margin-bottom: 2rem;
404 - }
405 -
406 - .info-card {
407 - display: flex;
408 - flex-direction: column;
409 - gap: 0.25rem;
410 - padding: 1rem;
411 - background: var(--hover-bg, rgba(255, 255, 255, 0.03));
412 - border-radius: 12px;
413 - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
414 - }
415 -
416 - .info-label {
417 - font-size: 0.75rem;
418 - text-transform: uppercase;
419 - letter-spacing: 0.05em;
420 - color: var(--text-tertiary, #666);
421 - }
422 -
423 - .info-value {
424 - font-family: 'DM Mono', monospace;
425 - font-size: 1.25rem;
426 - color: var(--text-primary, #fff);
427 - }
428 -
429 - .info-desc {
430 - font-size: 0.8rem;
431 - color: var(--text-secondary, #aaa);
432 - line-height: 1.4;
433 - }
434 -
435 - /* ═══════════════════════════════════════════════════════════════
436 - DESCRIPCIONES
437 - ═══════════════════════════════════════════════════════════════ */
438 - .descripciones-section {
439 - border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
440 - padding-top: 2rem;
441 - }
442 -
443 - .descripciones-title {
444 - font-family: 'Instrument Sans', sans-serif;
445 - font-size: 0.875rem;
446 - font-weight: 600;
447 - color: var(--text-secondary, #aaa);
448 - margin-bottom: 1rem;
449 - }
450 -
451 - .descripciones-list {
452 - display: flex;
453 - flex-direction: column;
454 - gap: 1rem;
455 - }
456 -
457 - .descripcion-item {
458 - padding: 1rem;
459 - border-left: 2px solid var(--border-color, rgba(255, 255, 255, 0.1));
460 - border-radius: 0 8px 8px 0;
461 - background: var(--hover-bg, rgba(255, 255, 255, 0.02));
462 - }
463 -
464 - .descripcion-item.vigente {
465 - border-left-color: var(--theme-accent, #3b82f6);
466 - background: color-mix(in srgb, var(--theme-accent, #3b82f6) 10%, transparent);
467 - }
468 -
469 - .descripcion-rangos {
470 - font-family: 'DM Mono', monospace;
471 - font-size: 0.75rem;
472 - color: var(--text-tertiary, #666);
473 - margin-bottom: 0.5rem;
474 - }
475 -
476 - .vigente-badge {
477 - display: inline-block;
478 - padding: 0.125rem 0.5rem;
479 - border-radius: 999px;
480 - background: var(--theme-accent, #3b82f6);
481 - color: white;
482 - font-size: 0.65rem;
483 - font-weight: 600;
484 - text-transform: uppercase;
485 - letter-spacing: 0.05em;
486 - margin-right: 0.5rem;
487 - }
488 -
489 - .descripcion-texto {
490 - font-size: 0.9rem;
491 - color: var(--text-primary, #fff);
492 - line-height: 1.6;
493 - }
494 -</style>
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>
This diff could not be displayed because it is too large.
1 -<script>
2 - import { onMount } from 'svelte';
3 - import * as d3 from 'd3';
4 - import * as Plot from '@observablehq/plot';
5 -
6 - // Estado reactivo
7 - let codigoSeleccionado = $state('1901');
8 - let gestionSeleccionada = $state(2025);
9 - let resumenData = $state([]);
10 - let distribucionesData = $state([]);
11 - let codigosDisponibles = $state([]);
12 - let cargando = $state(true);
13 -
14 - // Datos derivados
15 - let tipoCodigo = $derived.by(() => {
16 - const row = resumenData.find(d => String(d.codigo) === codigoSeleccionado);
17 - return row?.tipo_codigo || 'entidad';
18 - });
19 -
20 - let esEntidad = $derived(tipoCodigo === 'entidad');
21 -
22 - let resumenFiltrado = $derived(
23 - resumenData.filter(d => String(d.codigo) === codigoSeleccionado)
24 - );
25 -
26 - let historiaGastos = $derived(
27 - resumenFiltrado
28 - .filter(d => d.tipo === 'gastos')
29 - .sort((a, b) => a.gestion - b.gestion)
30 - );
31 -
32 - let historiaIngresos = $derived(
33 - resumenFiltrado
34 - .filter(d => d.tipo === 'ingresos')
35 - .sort((a, b) => a.gestion - b.gestion)
36 - );
37 -
38 - let gestiones = $derived(() => {
39 - const years = [...new Set(resumenFiltrado.map(d => d.gestion))].sort();
40 - return years;
41 - });
42 -
43 - let rankingGastos = $derived(() => {
44 - const row = historiaGastos.find(d => d.gestion === gestionSeleccionada);
45 - return row ? { posicion: row.ranking, total: '~600' } : null;
46 - });
47 -
48 - let rankingIngresos = $derived(() => {
49 - const row = historiaIngresos.find(d => d.gestion === gestionSeleccionada);
50 - return row ? { posicion: row.ranking, total: '~600' } : null;
51 - });
52 -
53 - // Distribuciones filtradas por codigo y gestion
54 - let distFiltradas = $derived(
55 - distribucionesData.filter(d =>
56 - String(d.codigo) === codigoSeleccionado &&
57 - d.gestion === gestionSeleccionada
58 - )
59 - );
60 -
61 - // Clasificadores: cada hijo es un segmento de la barra
62 - function prepararSegmentos(data) {
63 - return data
64 - .map(d => ({
65 - codigo: d.hijo,
66 - nombre: d.desc_hijo,
67 - monto: d.devengado,
68 - padre: d.desc_padre
69 - }))
70 - .filter(d => d.monto > 0)
71 - .sort((a, b) => b.monto - a.monto);
72 - }
73 -
74 - let clasificadoresGasto = $derived.by(() => {
75 - const gastos = distFiltradas.filter(d => d.tipo === 'gastos');
76 - const dims = [
77 - { key: 'objeto', label: 'Objetos de gasto' },
78 - { key: 'finfun', label: 'Finalidad y función' },
79 - { key: 'acteco', label: 'Sectores económicos' }
80 - ];
81 - return dims
82 - .map(dim => ({
83 - ...dim,
84 - data: prepararSegmentos(gastos.filter(d => d.dimension === dim.key))
85 - }))
86 - .filter(dim => dim.data.length > 0);
87 - });
88 -
89 - let clasificadoresIngreso = $derived.by(() => {
90 - if (!esEntidad) return [];
91 - const ingresos = distFiltradas.filter(d => d.tipo === 'ingresos');
92 - const dims = [
93 - { key: 'rubro', label: 'Rubros de ingreso' },
94 - { key: 'organismo', label: 'Organismos financiadores' }
95 - ];
96 - return dims
97 - .map(dim => ({
98 - ...dim,
99 - data: prepararSegmentos(ingresos.filter(d => d.dimension === dim.key))
100 - }))
101 - .filter(dim => dim.data.length > 0);
102 - });
103 -
104 - // Contenedores D3
105 - let barContainers = $state({});
106 - let hoverData = $state({});
107 - let chartIngresosContainer = $state(null);
108 - let chartGastosContainer = $state(null);
109 -
110 - // Totales
111 - function getTotal(data) {
112 - return data.reduce((sum, d) => sum + d.monto, 0);
113 - }
114 -
115 - // Formatear montos
116 - function formatearMonto(valor) {
117 - if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)} mil millones`;
118 - if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)} millones`;
119 - if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)} mil`;
120 - return valor.toFixed(0);
121 - }
122 -
123 - function formatearMontoCorto(valor) {
124 - if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
125 - if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
126 - if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)}K`;
127 - return valor.toFixed(0);
128 - }
129 -
130 - // Colores del tema
131 - function getChartColors() {
132 - const root = document.documentElement;
133 - const isDark = root.classList.contains('dark');
134 - return {
135 - barDefault: isDark ? '#3a3a38' : '#e0e0e0',
136 - barHighlight: isDark ? '#C9A751' : '#F3EAD8',
137 - barStroke: isDark ? '#5a5a55' : '#b0b0b0',
138 - barStrokeDefault: isDark ? '#2a2a28' : 'white'
139 - };
140 - }
141 -
142 - // Renderizar barras D3
143 - function renderizarBarras(key, data) {
144 - const container = barContainers[key];
145 - if (!container || data.length === 0) return;
146 -
147 - const total = getTotal(data);
148 - const height = 48;
149 - const radius = 4;
150 -
151 - d3.select(container).selectAll('*').remove();
152 -
153 - const containerRect = container.getBoundingClientRect();
154 - const width = containerRect.width || 800;
155 -
156 - const svg = d3.select(container)
157 - .append('svg')
158 - .attr('width', '100%')
159 - .attr('height', height)
160 - .attr('viewBox', `0 0 ${width} ${height}`)
161 - .attr('preserveAspectRatio', 'none')
162 - .style('display', 'block');
163 -
164 - let cumulative = 0;
165 - const segments = data.map(d => {
166 - const segWidth = (d.monto / total) * width;
167 - const segment = { ...d, x: cumulative, width: segWidth };
168 - cumulative += segWidth;
169 - return segment;
170 - });
171 -
172 - const chartColors = getChartColors();
173 - const colorDefault = chartColors.barDefault;
174 - const colorHover = chartColors.barHighlight;
175 - const colorFirst = chartColors.barHighlight;
176 -
177 - const bars = svg.selectAll('rect')
178 - .data(segments)
179 - .enter()
180 - .append('rect')
181 - .attr('x', d => d.x)
182 - .attr('y', 2)
183 - .attr('width', d => Math.max(d.width - 1, 1))
184 - .attr('height', height - 4)
185 - .attr('rx', (d, i) => {
186 - if (i === 0) return radius;
187 - if (i === segments.length - 1) return radius;
188 - return 0;
189 - })
190 - .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
191 - .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
192 - .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
193 - .style('cursor', 'pointer')
194 - .on('mouseenter', function(event, d) {
195 - bars
196 - .attr('fill', colorDefault)
197 - .attr('stroke', chartColors.barStrokeDefault)
198 - .attr('stroke-width', 0.5);
199 - d3.select(this)
200 - .attr('fill', colorHover)
201 - .attr('stroke', chartColors.barStroke)
202 - .attr('stroke-width', 1);
203 - hoverData[key] = d;
204 - hoverData = { ...hoverData };
205 - })
206 - .on('mouseleave', function() {
207 - bars
208 - .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
209 - .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
210 - .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
211 - hoverData[key] = null;
212 - hoverData = { ...hoverData };
213 - });
214 - }
215 -
216 - // Item a mostrar (hover o primero)
217 - function getDisplayItem(key, data) {
218 - const hover = hoverData[key];
219 - if (hover) return hover;
220 - return data[0] || null;
221 - }
222 -
223 - // Renderizar gráficos de historia
224 - function renderizarHistoria() {
225 - if (esEntidad && chartIngresosContainer && historiaIngresos.length > 0) {
226 - chartIngresosContainer.innerHTML = '';
227 - const plot = Plot.plot({
228 - width: chartIngresosContainer.clientWidth,
229 - height: 180,
230 - marginLeft: 50,
231 - marginRight: 20,
232 - marginTop: 20,
233 - marginBottom: 30,
234 - style: { background: 'transparent', fontFamily: 'var(--font-sans)' },
235 - x: { label: null, tickFormat: d => d },
236 - y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
237 - marks: [
238 - Plot.areaY(historiaIngresos, { x: 'gestion', y: 'devengado', fill: '#4A8B6E', fillOpacity: 0.3 }),
239 - Plot.lineY(historiaIngresos, { x: 'gestion', y: 'devengado', stroke: '#4A8B6E', strokeWidth: 2 }),
240 - Plot.dot(historiaIngresos, { x: 'gestion', y: 'devengado', fill: '#4A8B6E', r: 3 })
241 - ]
242 - });
243 - chartIngresosContainer.appendChild(plot);
244 - }
245 -
246 - if (chartGastosContainer && historiaGastos.length > 0) {
247 - chartGastosContainer.innerHTML = '';
248 - const plot = Plot.plot({
249 - width: chartGastosContainer.clientWidth,
250 - height: 180,
251 - marginLeft: 50,
252 - marginRight: 20,
253 - marginTop: 20,
254 - marginBottom: 30,
255 - style: { background: 'transparent', fontFamily: 'var(--font-sans)' },
256 - x: { label: null, tickFormat: d => d },
257 - y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
258 - marks: [
259 - Plot.areaY(historiaGastos, { x: 'gestion', y: 'devengado', fill: '#C9A751', fillOpacity: 0.3 }),
260 - Plot.lineY(historiaGastos, { x: 'gestion', y: 'devengado', stroke: '#C9A751', strokeWidth: 2 }),
261 - Plot.dot(historiaGastos, { x: 'gestion', y: 'devengado', fill: '#C9A751', r: 3 })
262 - ]
263 - });
264 - chartGastosContainer.appendChild(plot);
265 - }
266 - }
267 -
268 - // Renderizar todas las barras
269 - function renderizarTodasLasBarras() {
270 - setTimeout(() => {
271 - clasificadoresGasto.forEach(({ key, data }) => {
272 - if (barContainers[key]) renderizarBarras(key, data);
273 - });
274 - clasificadoresIngreso.forEach(({ key, data }) => {
275 - if (barContainers[key]) renderizarBarras(key, data);
276 - });
277 - }, 100);
278 - }
279 -
280 - // Efectos
281 - $effect(() => {
282 - // Trigger on data changes
283 - clasificadoresGasto;
284 - clasificadoresIngreso;
285 - renderizarTodasLasBarras();
286 - });
287 -
288 - $effect(() => {
289 - if (chartGastosContainer) {
290 - // Trigger on history data changes
291 - historiaGastos;
292 - historiaIngresos;
293 - esEntidad;
294 - setTimeout(renderizarHistoria, 150);
295 - }
296 - });
297 -
298 - onMount(async () => {
299 - // Cargar CSVs
300 - const [resumenRaw, distRaw] = await Promise.all([
301 - fetch('/resumen.csv').then(r => r.text()),
302 - fetch('/distribuciones.csv').then(r => r.text())
303 - ]);
304 -
305 - resumenData = d3.csvParse(resumenRaw, d => ({
306 - tipo: d.tipo,
307 - codigo: d.codigo,
308 - gestion: +d.gestion,
309 - devengado: +d.devengado,
310 - ranking: +d.ranking,
311 - tipo_codigo: d.tipo_codigo
312 - }));
313 -
314 - distribucionesData = d3.csvParse(distRaw, d => ({
315 - tipo: d.tipo,
316 - codigo: d.codigo,
317 - dimension: d.dimension,
318 - gestion: +d.gestion,
319 - padre: d.padre,
320 - desc_padre: d.desc_padre,
321 - hijo: d.hijo,
322 - desc_hijo: d.desc_hijo,
323 - devengado: +d.devengado,
324 - tipo_codigo: d.tipo_codigo
325 - }));
326 -
327 - // Extraer codigos únicos
328 - codigosDisponibles = [...new Set(resumenData.map(d => d.codigo))].sort((a, b) => {
329 - return parseFloat(a) - parseFloat(b);
330 - });
331 -
332 - cargando = false;
333 -
334 - // Observer para tema
335 - const observer = new MutationObserver(() => {
336 - renderizarTodasLasBarras();
337 - });
338 - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
339 -
340 - // Resize observer para charts
341 - const resizeObserver = new ResizeObserver(() => {
342 - renderizarHistoria();
343 - });
344 - if (chartIngresosContainer) resizeObserver.observe(chartIngresosContainer);
345 - if (chartGastosContainer) resizeObserver.observe(chartGastosContainer);
346 -
347 - return () => {
348 - observer.disconnect();
349 - resizeObserver.disconnect();
350 - };
351 - });
352 -</script>
353 -
354 -<svelte:head>
355 - <title>Radiografía - {codigoSeleccionado}</title>
356 -</svelte:head>
357 -
358 -{#if cargando}
359 - <div class="dashboard">
360 - <p>Cargando datos...</p>
361 - </div>
362 -{:else}
363 -<div class="dashboard">
364 - <!-- Header sticky -->
365 - <header class="sticky-header">
366 - <div class="sticky-row">
367 - <div class="sticky-left">
368 - <h1 class="entidad-nombre">Código: {codigoSeleccionado}</h1>
369 - <div class="entidad-meta">
370 - <span class="tipo-badge" class:da={!esEntidad}>{tipoCodigo}</span>
371 - <span class="entidad-sector">{esEntidad ? 'Gastos e Ingresos' : 'Solo Gastos'}</span>
372 - </div>
373 - </div>
374 - <div class="sticky-controls">
375 - <select class="control-select" bind:value={codigoSeleccionado}>
376 - {#each codigosDisponibles as cod}
377 - <option value={cod}>{cod}</option>
378 - {/each}
379 - </select>
380 - <select class="control-select" bind:value={gestionSeleccionada}>
381 - {#each gestiones() as g}
382 - <option value={g}>{g}</option>
383 - {/each}
384 - </select>
385 - </div>
386 - </div>
387 - </header>
388 -
389 - <!-- Ranking -->
390 - <div class="ranking-bar">
391 - {#if rankingGastos()}
392 - <div class="ranking-item">
393 - <span class="ranking-label">Ranking gastos</span>
394 - <span class="ranking-value">#{rankingGastos().posicion}</span>
395 - <span class="ranking-total">de {rankingGastos().total}</span>
396 - </div>
397 - {/if}
398 - {#if esEntidad && rankingIngresos()}
399 - <div class="ranking-sep"></div>
400 - <div class="ranking-item">
401 - <span class="ranking-label">Ranking ingresos</span>
402 - <span class="ranking-value">#{rankingIngresos().posicion}</span>
403 - <span class="ranking-total">de {rankingIngresos().total}</span>
404 - </div>
405 - {/if}
406 - </div>
407 -
408 - <!-- Historia temporal -->
409 - <section class="seccion">
410 - <h2 class="seccion-titulo">{historiaGastos.length > 0 ? `${historiaGastos[0].gestion} – ${historiaGastos[historiaGastos.length - 1].gestion}` : ''}</h2>
411 - <div class="historia-grid" class:single={!esEntidad}>
412 - {#if esEntidad}
413 - <div class="historia-card">
414 - <div class="historia-header">
415 - <span class="historia-label">Ingresos</span>
416 - {#if historiaIngresos.length > 0}
417 - <span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].devengado)} Bs</span>
418 - {/if}
419 - </div>
420 - <div class="historia-chart" bind:this={chartIngresosContainer}></div>
421 - </div>
422 - {/if}
423 - <div class="historia-card">
424 - <div class="historia-header">
425 - <span class="historia-label">Gastos</span>
426 - {#if historiaGastos.length > 0}
427 - <span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].devengado)} Bs</span>
428 - {/if}
429 - </div>
430 - <div class="historia-chart" bind:this={chartGastosContainer}></div>
431 - </div>
432 - </div>
433 - </section>
434 -
435 - <!-- Composición del gasto -->
436 - {#if clasificadoresGasto.length > 0}
437 - <section class="seccion">
438 - <h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
439 - <div class="clasificadores-grid">
440 - {#each clasificadoresGasto as { key, label, data }}
441 - {@const displayItem = getDisplayItem(key, data)}
442 - <div class="clasificador-card">
443 - <div class="clasificador-header">
444 - <div class="clasificador-info">
445 - <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
446 - <span class="clasificador-padre">{displayItem?.padre || ''}</span>
447 - <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
448 - </div>
449 - <span class="clasificador-label">{label}</span>
450 - </div>
451 - <div class="barra-container" bind:this={barContainers[key]}></div>
452 - </div>
453 - {/each}
454 - </div>
455 - </section>
456 - {/if}
457 -
458 - <!-- Origen del dinero (solo entidad) -->
459 - {#if esEntidad && clasificadoresIngreso.length > 0}
460 - <section class="seccion">
461 - <h2 class="seccion-titulo">Fuentes de ingreso y organismos financiadores <span class="gestion-badge">{gestionSeleccionada}</span></h2>
462 - <div class="clasificadores-grid">
463 - {#each clasificadoresIngreso as { key, label, data }}
464 - {@const displayItem = getDisplayItem(key, data)}
465 - <div class="clasificador-card">
466 - <div class="clasificador-header">
467 - <div class="clasificador-info">
468 - <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
469 - <span class="clasificador-padre">{displayItem?.padre || ''}</span>
470 - <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
471 - </div>
472 - <span class="clasificador-label">{label}</span>
473 - </div>
474 - <div class="barra-container" bind:this={barContainers[key]}></div>
475 - </div>
476 - {/each}
477 - </div>
478 - </section>
479 - {/if}
480 -</div>
481 -{/if}
482 -
483 -<style>
484 - .dashboard {
485 - min-height: 100vh;
486 - background: var(--theme-fondo);
487 - color: var(--theme-texto);
488 - padding: 2rem;
489 - padding-top: 2rem;
490 - font-family: var(--font-sans);
491 - }
492 -
493 - /* Sticky header */
494 - .sticky-header {
495 - position: sticky;
496 - top: 0;
497 - z-index: 50;
498 - padding: 0.75rem 2rem;
499 - margin: 0 -2rem 1.5rem -2rem;
500 - background: color-mix(in srgb, var(--theme-fondo) 90%, transparent);
501 - border-bottom: 1px solid var(--theme-borde);
502 - backdrop-filter: blur(12px);
503 - -webkit-backdrop-filter: blur(12px);
504 - }
505 -
506 - .sticky-row {
507 - display: flex;
508 - align-items: center;
509 - gap: 1rem;
510 - max-width: calc(100% - 220px); /* dejar espacio para el navbar fixed */
511 - }
512 -
513 - .sticky-left {
514 - display: flex;
515 - align-items: baseline;
516 - gap: 0.75rem;
517 - min-width: 0;
518 - }
519 -
520 - .entidad-nombre {
521 - font-size: 1.1rem;
522 - font-weight: 700;
523 - color: var(--theme-titulo);
524 - margin: 0;
525 - line-height: 1.2;
526 - white-space: nowrap;
527 - overflow: hidden;
528 - text-overflow: ellipsis;
529 - }
530 -
531 - .entidad-meta {
532 - display: flex;
533 - align-items: center;
534 - gap: 0.4rem;
535 - flex-shrink: 0;
536 - }
537 -
538 - .entidad-sector {
539 - font-size: 0.7rem;
540 - color: var(--theme-texto);
541 - opacity: 0.6;
542 - }
543 -
544 - .tipo-badge {
545 - font-size: 0.6rem;
546 - font-weight: 600;
547 - padding: 0.15rem 0.45rem;
548 - background: rgba(74, 139, 110, 0.15);
549 - color: #4A8B6E;
550 - border-radius: 4px;
551 - text-transform: uppercase;
552 - letter-spacing: 0.03em;
553 - }
554 -
555 - .tipo-badge.da {
556 - background: rgba(201, 167, 81, 0.15);
557 - color: #C9A751;
558 - }
559 -
560 - .sticky-controls {
561 - display: flex;
562 - align-items: center;
563 - gap: 0.4rem;
564 - flex-shrink: 0;
565 - }
566 -
567 - .control-select {
568 - padding: 0.35rem 0.6rem;
569 - font-size: 0.8rem;
570 - font-weight: 600;
571 - background: var(--theme-tarjeta);
572 - border: 1px solid var(--theme-borde);
573 - border-radius: 6px;
574 - color: var(--theme-titulo);
575 - cursor: pointer;
576 - appearance: none;
577 - -webkit-appearance: none;
578 - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
579 - background-repeat: no-repeat;
580 - background-position: right 0.4rem center;
581 - padding-right: 1.5rem;
582 - }
583 -
584 - /* Ranking */
585 - .ranking-bar {
586 - display: flex;
587 - align-items: center;
588 - gap: 1.5rem;
589 - padding: 1rem 1.5rem;
590 - background: var(--theme-tarjeta);
591 - border-radius: 12px;
592 - margin-bottom: 2rem;
593 - }
594 -
595 - .ranking-item {
596 - display: flex;
597 - align-items: baseline;
598 - gap: 0.5rem;
599 - }
600 -
601 - .ranking-label {
602 - font-size: 0.8rem;
603 - color: var(--theme-texto);
604 - opacity: 0.7;
605 - }
606 -
607 - .ranking-value {
608 - font-size: 1.25rem;
609 - font-weight: 700;
610 - color: #C9A751;
611 - }
612 -
613 - .ranking-total {
614 - font-size: 0.8rem;
615 - color: var(--theme-texto);
616 - opacity: 0.5;
617 - }
618 -
619 - .ranking-sep {
620 - width: 1px;
621 - height: 24px;
622 - background: var(--theme-borde);
623 - }
624 -
625 - /* Secciones */
626 - .seccion {
627 - margin-bottom: 2.5rem;
628 - }
629 -
630 - .seccion-titulo {
631 - font-size: 1.1rem;
632 - font-weight: 600;
633 - color: var(--theme-titulo);
634 - margin: 0 0 1rem 0;
635 - display: flex;
636 - align-items: center;
637 - gap: 0.75rem;
638 - }
639 -
640 - .gestion-badge {
641 - font-size: 0.75rem;
642 - font-weight: 500;
643 - padding: 0.25rem 0.6rem;
644 - background: rgba(201, 167, 81, 0.15);
645 - color: #C9A751;
646 - border-radius: 4px;
647 - }
648 -
649 - /* Historia */
650 - .historia-grid {
651 - display: grid;
652 - grid-template-columns: repeat(2, 1fr);
653 - gap: 1.5rem;
654 - }
655 -
656 - .historia-grid.single {
657 - grid-template-columns: 1fr;
658 - }
659 -
660 - .historia-card {
661 - background: var(--theme-tarjeta);
662 - border-radius: 12px;
663 - padding: 1rem;
664 - }
665 -
666 - .historia-header {
667 - display: flex;
668 - justify-content: space-between;
669 - align-items: baseline;
670 - margin-bottom: 0.75rem;
671 - }
672 -
673 - .historia-label {
674 - font-size: 0.85rem;
675 - font-weight: 500;
676 - color: var(--theme-texto);
677 - }
678 -
679 - .historia-monto {
680 - font-size: 0.95rem;
681 - font-weight: 600;
682 - }
683 -
684 - .historia-monto.ingresos {
685 - color: #4A8B6E;
686 - }
687 -
688 - .historia-monto.gastos {
689 - color: #C9A751;
690 - }
691 -
692 - .historia-chart {
693 - width: 100%;
694 - height: 180px;
695 - }
696 -
697 - /* Clasificadores */
698 - .clasificadores-grid {
699 - display: flex;
700 - flex-direction: column;
701 - gap: 1rem;
702 - }
703 -
704 - .clasificador-card {
705 - display: flex;
706 - flex-direction: column;
707 - gap: 0.35rem;
708 - }
709 -
710 - .clasificador-header {
711 - display: flex;
712 - align-items: baseline;
713 - justify-content: space-between;
714 - gap: 1rem;
715 - }
716 -
717 - .clasificador-info {
718 - display: flex;
719 - align-items: baseline;
720 - gap: 0.4rem;
721 - flex-wrap: wrap;
722 - }
723 -
724 - .clasificador-monto {
725 - font-size: 0.95rem;
726 - font-weight: 600;
727 - color: var(--theme-titulo);
728 - }
729 -
730 - .clasificador-padre {
731 - font-size: 0.8rem;
732 - font-weight: 400;
733 - color: var(--theme-texto);
734 - opacity: 0.5;
735 - }
736 -
737 - .clasificador-nombre {
738 - font-size: 0.85rem;
739 - font-weight: 500;
740 - color: var(--theme-titulo);
741 - }
742 -
743 - .clasificador-label {
744 - font-size: 0.65rem;
745 - text-transform: uppercase;
746 - letter-spacing: 0.05em;
747 - color: var(--theme-texto);
748 - opacity: 0.6;
749 - flex-shrink: 0;
750 - }
751 -
752 - .barra-container {
753 - width: 100%;
754 - height: 48px;
755 - border-radius: 4px;
756 - overflow: hidden;
757 - }
758 -
759 - /* Responsive */
760 - @media (max-width: 768px) {
761 - .dashboard {
762 - padding: 1rem;
763 - }
764 -
765 - .sticky-header {
766 - margin: 0 -1rem 1rem -1rem;
767 - padding: 0.5rem 1rem;
768 - }
769 -
770 - .sticky-row {
771 - flex-direction: column;
772 - align-items: flex-start;
773 - gap: 0.5rem;
774 - max-width: calc(100% - 160px);
775 - }
776 -
777 - .sticky-left {
778 - flex-direction: column;
779 - gap: 0.25rem;
780 - }
781 -
782 - .entidad-nombre {
783 - font-size: 0.95rem;
784 - }
785 -
786 - .sticky-controls {
787 - width: 100%;
788 - }
789 -
790 - .control-select {
791 - flex: 1;
792 - }
793 -
794 - .historia-grid {
795 - grid-template-columns: 1fr;
796 - }
797 -
798 - .ranking-bar {
799 - flex-direction: column;
800 - align-items: flex-start;
801 - gap: 0.75rem;
802 - }
803 -
804 - .ranking-sep {
805 - width: 100%;
806 - height: 1px;
807 - }
808 - }
809 -</style>
1 -import { supabase } from '$lib/supabase';
2 -
3 -// Hardcoded a objeto 11700 (Sueldos) para testing de layout
4 -const CODIGO = '11700';
5 -
6 -function getGrupoCode(objeto) { return objeto.charAt(0) + '0000'; }
7 -function getSubgrupoCode(objeto) { return objeto.substring(0, 2) + '000'; }
8 -function getPartidaCode(objeto) { return objeto.substring(0, 3) + '00'; }
9 -
10 -export async function load() {
11 - const { data } = await supabase.schema('ppto').from('clas_objetos').select('*').eq('objeto', CODIGO);
12 - const objeto = data[0];
13 -
14 - const grupoCode = getGrupoCode(CODIGO);
15 - const subgrupoCode = getSubgrupoCode(CODIGO);
16 - const partidaCode = getPartidaCode(CODIGO);
17 -
18 - const { data: padresData } = await supabase.schema('ppto').from('clas_objetos').select('*').in('objeto', [grupoCode, subgrupoCode, partidaCode]).order('objeto');
19 - const padres = padresData || [];
20 -
21 - // Contar entidades
22 - const nEntidades = objeto.n_entidades || 0;
23 -
24 - return { objeto, padres, hijos: [], nEntidades };
25 -}
1 -<script>
2 - import { onMount } from 'svelte';
3 - import { tweened } from 'svelte/motion';
4 - import { cubicOut } from 'svelte/easing';
5 - import { supabase } from '$lib/supabase';
6 - import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
7 - import BarChart from '$lib/components/objeto/BarChart.svelte';
8 - import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
9 - import { initIndex } from '$lib/stores/searchStore';
10 -
11 - let { data } = $props();
12 - let objetoData = $derived(data.objeto);
13 - let padres = $derived(data.padres);
14 - let hijos = $derived(data.hijos);
15 -
16 - let mounted = $state(false);
17 - let loading = $state(true);
18 - let hoveredYear = $state(null);
19 -
20 - // Estado selector entidad
21 - let entidades = $state([]);
22 - let selectedEntity = $state(null);
23 - let nEntidades = $derived(data.nEntidades);
24 -
25 - // Datos
26 - let datosAnuales = $state([]);
27 - let datosHistorico = $state(null);
28 - let topEntidadesPorAño = $state({});
29 -
30 - const POBLACION = 12000000;
31 - let objetoCodigo = $derived(objetoData.objeto);
32 - let objetoNivel = $derived(objetoData.nivel);
33 -
34 - let gastoPerCapita = $derived(
35 - datosAnuales.map(d => ({
36 - año: d.gestion,
37 - monto: selectedEntity ? (d.monto ?? d.devengado) : d.total,
38 - perCapita: d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)
39 - }))
40 - );
41 -
42 - let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
43 - let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
44 -
45 - let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
46 - let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
47 - let totalPorcentaje = $derived(datosHistorico?.prop || 0);
48 - let ranking = $derived(datosHistorico?.ranking || 0);
49 - let nPares = $derived(datosHistorico?.n_pares || 0);
50 -
51 - let currentData = $derived(
52 - hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
53 - );
54 -
55 - let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
56 - let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
57 - let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
58 - let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
59 - let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
60 - let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
61 - let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
62 - let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
63 - let displayRankingLabel = $derived(currentData ? `partidas más gastadas en ${currentData.año}` : 'partidas más gastadas');
64 -
65 - // Top entidades
66 - let topEntidadesTotal = $derived(
67 - datosHistorico && !selectedEntity ? [
68 - { nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
69 - { nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
70 - { nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
71 - ].filter(e => e.nombre) : []
72 - );
73 - let displayTopEntidades = $derived(
74 - hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
75 - );
76 -
77 - // Tweens
78 - const tw = { duration: 300, easing: cubicOut };
79 - const twMonto = tweened(0, tw);
80 - const twPerCapita = tweened(0, tw);
81 - const twPorcentaje = tweened(0, tw);
82 - const twTop1 = tweened(0, tw);
83 - const twTop2 = tweened(0, tw);
84 - const twTop3 = tweened(0, tw);
85 -
86 - $effect(() => { twMonto.set(displayMonto); });
87 - $effect(() => { twPerCapita.set(displayPerCapita); });
88 - $effect(() => { twPorcentaje.set(displayPorcentaje); });
89 - $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
90 - $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
91 - $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
92 -
93 - function formatMontoLargo(n) {
94 - if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
95 - if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
96 - if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
97 - return n.toLocaleString('es-BO');
98 - }
99 -
100 - function formatPerCapita(n) {
101 - return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
102 - }
103 -
104 - async function loadData() {
105 - loading = true;
106 - if (selectedEntity) {
107 - const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
108 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion');
109 - if (data) {
110 - datosHistorico = data.find(d => d.gestion === 0) || null;
111 - datosAnuales = data.filter(d => d.gestion > 0);
112 - topEntidadesPorAño = {};
113 - }
114 - } else {
115 - const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
116 - .eq('objeto', objetoCodigo).order('gestion');
117 - if (data) {
118 - datosHistorico = data.find(d => d.gestion === 0) || null;
119 - datosAnuales = data.filter(d => d.gestion > 0);
120 - topEntidadesPorAño = {};
121 - datosAnuales.forEach(d => {
122 - topEntidadesPorAño[d.gestion] = [
123 - { nombre: d.top1_entidad, porcentaje: d.top1_pct },
124 - { nombre: d.top2_entidad, porcentaje: d.top2_pct },
125 - { nombre: d.top3_entidad, porcentaje: d.top3_pct },
126 - ].filter(e => e.nombre);
127 - });
128 - }
129 - }
130 - loading = false;
131 - }
132 -
133 - async function loadEntidades() {
134 - const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc')
135 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc');
136 - if (data) entidades = data;
137 - }
138 -
139 - onMount(async () => {
140 - mounted = true;
141 - initIndex();
142 - await Promise.all([loadData(), loadEntidades()]);
143 - });
144 -</script>
145 -
146 -<svelte:head>
147 - <link rel="preconnect" href="https://fonts.googleapis.com" />
148 - <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
149 -</svelte:head>
150 -
151 -<div class="page" class:mounted>
152 - <nav class="page-nav">
153 - <a href="/clasificadores/objeto-gasto" class="back-link">
154 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
155 - <path d="M19 12H5M12 19l-7-7 7-7"/>
156 - </svg>
157 - Clasificador
158 - </a>
159 - </nav>
160 -
161 - <div class="dashboard-layout">
162 - <!-- Sidebar -->
163 - <aside class="dashboard-context">
164 - <div class="context-card">
165 - <h2 class="context-main-title">Herramientas</h2>
166 -
167 - <!-- Buscar -->
168 - <div class="context-section">
169 - <h3 class="context-subtitle">Buscar</h3>
170 - <p class="context-hint">Buscar otro grupo, subgrupo, partida o subpartida</p>
171 - <ObjetoSearch />
172 - </div>
173 -
174 - <div class="context-separator"></div>
175 -
176 - <!-- Navegación -->
177 - {#if padres.length > 0 || hijos.length > 0}
178 - <div class="context-section">
179 - <h3 class="context-subtitle">Navegación</h3>
180 - <p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
181 - <div class="tree">
182 - {#each padres as padre, i}
183 - <a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
184 - <span class="tree-dot"></span>
185 - <span class="tree-code">{padre.objeto}</span>
186 - <span class="tree-name">{padre.desc_objeto}</span>
187 - </a>
188 - {/each}
189 -
190 - <div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
191 - <span class="tree-dot"></span>
192 - <span class="tree-code">{objetoData.objeto}</span>
193 - <span class="tree-name">{objetoData.desc_objeto}</span>
194 - </div>
195 -
196 - {#if hijos.length > 0}
197 - <div class="tree-children" style="--indent: {padres.length + 1}">
198 - {#each hijos as hijo}
199 - <a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
200 - <span class="tree-dot"></span>
201 - <span class="tree-code">{hijo.objeto}</span>
202 - <span class="tree-name">{hijo.desc_objeto}</span>
203 - </a>
204 - {/each}
205 - </div>
206 - {/if}
207 - </div>
208 - </div>
209 -
210 - <div class="context-separator"></div>
211 - {/if}
212 -
213 - <!-- Explorar -->
214 - <div class="context-section">
215 - <h3 class="context-subtitle">Explorar visualizaciones</h3>
216 - <div class="explore-scroll">
217 - <a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
218 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
219 - <rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
220 - </svg>
221 - <span class="explore-title">Comparar dos entidades</span>
222 - <span class="explore-desc">¿Quién gasta más en sueldos, el Min. de Salud o Defensa?</span>
223 - </a>
224 - <a href="/clasificadores/objeto-gasto" class="explore-card">
225 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
226 - <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
227 - </svg>
228 - <span class="explore-title">Ranking</span>
229 - <span class="explore-desc">{nEntidades} entidades que gastan en esta partida</span>
230 - </a>
231 - <a href="/ubicacion" class="explore-card explore-card-disabled">
232 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
233 - <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
234 - </svg>
235 - <span class="explore-title">Mapa</span>
236 - <span class="explore-desc">Dónde se concentra el gasto en esta partida</span>
237 - </a>
238 - <a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
239 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
240 - <circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
241 - </svg>
242 - <span class="explore-title">Peso relativo</span>
243 - <span class="explore-desc">Cuánto pesan los sueldos respecto a otros gastos</span>
244 - </a>
245 - <a href="/clasificadores/objeto-gasto" class="explore-card">
246 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
247 - <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
248 - </svg>
249 - <span class="explore-title">Clasificador</span>
250 - <span class="explore-desc">Ver todas las partidas de gasto y navegar entre ellas</span>
251 - </a>
252 - </div>
253 - </div>
254 -
255 - <div class="context-separator"></div>
256 -
257 - <!-- Comparabilidad -->
258 - <div class="context-section">
259 - <h3 class="context-subtitle">Comparabilidad</h3>
260 - <div class="vigencia-compact">
261 - <div class="vigencia-status-icon vigencia-full">
262 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
263 - <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
264 - <polyline points="22 4 12 14.01 9 11.01"/>
265 - </svg>
266 - </div>
267 - <div class="vigencia-text">
268 - <span class="vigencia-main">Comparable en todo el período</span>
269 - <span class="vigencia-sub">2005 – 2025</span>
270 - </div>
271 - </div>
272 - </div>
273 -
274 - <div class="context-separator"></div>
275 -
276 - <!-- Definición -->
277 - <div class="context-section">
278 - <h3 class="context-subtitle">Definición</h3>
279 - <button class="def-link">
280 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
281 - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
282 - </svg>
283 - Ver variaciones en la definición
284 - </button>
285 - </div>
286 - </div>
287 - </aside>
288 -
289 - <!-- Main -->
290 - <main class="dashboard-main">
291 - <div class="chart-protagonista">
292 - <!-- Header -->
293 - <div class="chart-top-row">
294 - <div class="main-title-pills">
295 - <span class="pill pill-nivel">Subpartida</span>
296 - <span class="pill pill-codigo">{objetoData.objeto}</span>
297 - </div>
298 - </div>
299 -
300 - <h2 class="main-title-name">{objetoData.desc_objeto}</h2>
301 -
302 - <div class="chart-controls">
303 - <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
304 - </div>
305 -
306 - <div class="chart-header">
307 - <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
308 - </div>
309 -
310 - {#if loading}
311 - <div class="chart-loading" style="flex: 1; min-height: 100px;">
312 - <span>Cargando...</span>
313 - </div>
314 - {:else}
315 - <div class="chart-flex-wrapper">
316 - <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
317 - </div>
318 - {/if}
319 -
320 - <!-- KPIs -->
321 - <div class="kpis">
322 - <div class="kpi">
323 - <span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
324 - <span class="kpi-label">{displayMontoLabel}</span>
325 - </div>
326 - <div class="kpi">
327 - <span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
328 - <span class="kpi-label">{displayPerCapitaLabel}</span>
329 - </div>
330 - <div class="kpi">
331 - <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
332 - <span class="kpi-label">{displayPorcentajeLabel}</span>
333 - </div>
334 - <div class="kpi">
335 - <span class="kpi-value">{displayRanking}</span>
336 - <span class="kpi-label">{displayRankingLabel}</span>
337 - </div>
338 - </div>
339 - </div>
340 -
341 - {#if !selectedEntity && displayTopEntidades.length > 0}
342 - <div class="ranking-section">
343 - <h3 class="ranking-title">Concentración del gasto</h3>
344 - <p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
345 - <div class="ranking-cards">
346 - {#each displayTopEntidades as ent, i}
347 - {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
348 - <div class="ranking-card">
349 - <span class="ranking-position">#{i + 1}</span>
350 - <span class="ranking-name">{ent.nombre}</span>
351 - <span class="ranking-pct">{pct.toFixed(1)}%</span>
352 - </div>
353 - {/each}
354 - </div>
355 - </div>
356 - {/if}
357 - </main>
358 - </div>
359 -</div>
360 -
361 -<style>
362 - /* ═══════════════════════════════════════════════════════════════
363 - PAGE
364 - ═══════════════════════════════════════════════════════════════ */
365 - .page {
366 - min-height: 100vh;
367 - width: 100%;
368 - max-width: 100vw;
369 - overflow-x: hidden;
370 - background: var(--theme-body);
371 - color: var(--theme-titulo);
372 - font-family: var(--font-sans), -apple-system, sans-serif;
373 - opacity: 0;
374 - transition: opacity 0.4s ease;
375 - }
376 - .page.mounted { opacity: 1; }
377 -
378 - :global(html:not(.dark)) .page {
379 - background: #f5f5f7;
380 - }
381 -
382 - /* ═══════════════════════════════════════════════════════════════
383 - NAV
384 - ═══════════════════════════════════════════════════════════════ */
385 - .page-nav {
386 - padding: 2rem 2rem 0.5rem;
387 - max-width: 1400px;
388 - margin: 0 auto;
389 - }
390 -
391 - .back-link {
392 - display: inline-flex;
393 - align-items: center;
394 - gap: 0.375rem;
395 - font-family: 'DM Mono', monospace;
396 - font-size: 0.6875rem;
397 - color: var(--theme-texto);
398 - text-decoration: none;
399 - transition: color 0.2s;
400 - }
401 - .back-link:hover { color: var(--theme-accent); }
402 -
403 - /* ═══════════════════════════════════════════════════════════════
404 - LAYOUT
405 - ═══════════════════════════════════════════════════════════════ */
406 - .dashboard-layout {
407 - display: grid;
408 - grid-template-columns: minmax(200px, 30%) 1fr;
409 - gap: 1.5rem;
410 - max-width: 1400px;
411 - margin: 0 auto;
412 - padding: 1.5rem 2rem 2rem;
413 - height: calc(100vh - 4rem);
414 - overflow: hidden;
415 - }
416 -
417 - /* ═══════════════════════════════════════════════════════════════
418 - SIDEBAR
419 - ═══════════════════════════════════════════════════════════════ */
420 - .dashboard-context {
421 - display: flex;
422 - flex-direction: column;
423 - min-height: 0;
424 - }
425 -
426 - .context-card {
427 - background: var(--theme-surface);
428 - border-radius: 12px;
429 - padding: 0.75rem;
430 - display: flex;
431 - flex-direction: column;
432 - gap: 8px;
433 - flex: 1;
434 - min-height: 0;
435 - overflow-y: auto;
436 - scrollbar-width: thin;
437 - }
438 -
439 - .context-card::-webkit-scrollbar { width: 4px; }
440 - .context-card::-webkit-scrollbar-thumb {
441 - background: rgba(128, 128, 128, 0.3);
442 - border-radius: 2px;
443 - }
444 -
445 - .context-main-title {
446 - font-size: 1.375rem;
447 - font-weight: 600;
448 - color: var(--theme-titulo);
449 - margin: 0;
450 - line-height: 1.2;
451 - }
452 -
453 - .context-subtitle {
454 - font-family: 'Qanelas', var(--font-sans);
455 - font-size: 0.875rem;
456 - font-weight: 600;
457 - color: var(--theme-titulo);
458 - margin: 0;
459 - }
460 -
461 - .context-hint {
462 - font-size: 0.75rem;
463 - color: #666666;
464 - margin: 0;
465 - line-height: 1.4;
466 - }
467 -
468 - .context-section {
469 - display: flex;
470 - flex-direction: column;
471 - gap: 0.5rem;
472 - }
473 -
474 - .context-separator {
475 - border-top: 1px solid var(--theme-borde);
476 - margin: 0.25rem 0;
477 - }
478 -
479 - /* ═══════════════════════════════════════════════════════════════
480 - TREE
481 - ═══════════════════════════════════════════════════════════════ */
482 - .tree {
483 - font-size: 0.75rem;
484 - line-height: 1.6;
485 - }
486 -
487 - .tree-node {
488 - display: flex;
489 - align-items: center;
490 - gap: 0.375rem;
491 - padding: 0.375rem 0.5rem;
492 - margin-left: calc(var(--indent, 0) * 1rem);
493 - border-left: 2px solid transparent;
494 - border-radius: 0 6px 6px 0;
495 - text-decoration: none;
496 - color: var(--theme-texto);
497 - transition: all 0.15s;
498 - }
499 - .tree-node:hover { color: var(--theme-accent); }
500 -
501 - .tree-node-current {
502 - color: var(--theme-titulo);
503 - font-weight: 600;
504 - border-left-color: var(--theme-accent);
505 - }
506 -
507 - .tree-children {
508 - margin-left: calc(var(--indent, 0) * 1rem);
509 - }
510 -
511 - .tree-node-child {
512 - padding-left: 0.75rem;
513 - }
514 -
515 - .tree-dot {
516 - width: 4px;
517 - height: 4px;
518 - border-radius: 50%;
519 - background: currentColor;
520 - flex-shrink: 0;
521 - opacity: 0.4;
522 - }
523 -
524 - .tree-code {
525 - font-family: 'DM Mono', monospace;
526 - font-size: 0.6875rem;
527 - min-width: 2.5rem;
528 - opacity: 0.6;
529 - }
530 -
531 - .tree-name {
532 - font-size: 0.75rem;
533 - white-space: nowrap;
534 - overflow: hidden;
535 - text-overflow: ellipsis;
536 - max-width: 140px;
537 - }
538 -
539 - /* ═══════════════════════════════════════════════════════════════
540 - EXPLORE
541 - ═══════════════════════════════════════════════════════════════ */
542 - .explore-scroll {
543 - display: flex;
544 - gap: 8px;
545 - overflow-x: auto;
546 - scrollbar-width: none;
547 - padding-bottom: 2px;
548 - }
549 - .explore-scroll::-webkit-scrollbar { display: none; }
550 -
551 - .explore-card {
552 - position: relative;
553 - display: flex;
554 - flex-direction: column;
555 - justify-content: flex-start;
556 - padding: 0.375rem 0.5rem;
557 - width: 120px;
558 - height: 95px;
559 - border-radius: 8px;
560 - background: var(--theme-borde);
561 - text-decoration: none;
562 - color: var(--theme-texto);
563 - transition: all 0.15s;
564 - flex-shrink: 0;
565 - overflow: hidden;
566 - }
567 - .explore-card:hover {
568 - background: rgba(201, 167, 81, 0.15);
569 - color: var(--theme-accent);
570 - }
571 - .explore-card:hover .explore-watermark {
572 - opacity: 0.18;
573 - color: var(--theme-accent);
574 - }
575 - .explore-card:hover .explore-title {
576 - color: var(--theme-accent);
577 - }
578 -
579 - .explore-watermark {
580 - position: absolute;
581 - bottom: -4px;
582 - right: -4px;
583 - width: 44px;
584 - height: 44px;
585 - opacity: 0.15;
586 - pointer-events: none;
587 - color: var(--theme-accent, #C9A751);
588 - }
589 -
590 - .explore-title {
591 - position: relative;
592 - font-size: 0.75rem;
593 - font-weight: 600;
594 - line-height: 1.3;
595 - color: var(--theme-titulo);
596 - z-index: 1;
597 - }
598 -
599 - .explore-desc {
600 - position: relative;
601 - font-size: 0.625rem;
602 - line-height: 1.35;
603 - opacity: 0.6;
604 - margin-top: 3px;
605 - z-index: 1;
606 - }
607 -
608 - .explore-card-disabled {
609 - opacity: 0.3;
610 - pointer-events: none;
611 - }
612 -
613 - /* ═══════════════════════════════════════════════════════════════
614 - VIGENCIA
615 - ═══════════════════════════════════════════════════════════════ */
616 - .vigencia-compact {
617 - display: flex;
618 - align-items: center;
619 - gap: 0.75rem;
620 - }
621 -
622 - .vigencia-status-icon {
623 - width: 32px;
624 - height: 32px;
625 - border-radius: 8px;
626 - display: flex;
627 - align-items: center;
628 - justify-content: center;
629 - flex-shrink: 0;
630 - }
631 -
632 - .vigencia-status-icon.vigencia-full {
633 - background: rgba(74, 158, 107, 0.15);
634 - color: #4A9E6B;
635 - }
636 -
637 - .vigencia-text {
638 - display: flex;
639 - flex-direction: column;
640 - gap: 0.125rem;
641 - }
642 -
643 - .vigencia-main {
644 - font-size: 0.875rem;
645 - font-weight: 500;
646 - color: var(--theme-titulo);
647 - }
648 -
649 - .vigencia-sub {
650 - font-size: 0.6875rem;
651 - color: var(--theme-texto);
652 - opacity: 0.7;
653 - }
654 -
655 - /* ═══════════════════════════════════════════════════════════════
656 - DEFINICIÓN
657 - ═══════════════════════════════════════════════════════════════ */
658 - .def-link {
659 - display: flex;
660 - align-items: center;
661 - gap: 0.375rem;
662 - font-size: 0.75rem;
663 - color: var(--theme-accent, #C9A751);
664 - background: none;
665 - border: none;
666 - padding: 0;
667 - cursor: pointer;
668 - transition: opacity 0.15s;
669 - }
670 - .def-link:hover { opacity: 0.7; }
671 - .def-link svg { flex-shrink: 0; opacity: 0.8; }
672 -
673 - /* ═══════════════════════════════════════════════════════════════
674 - MAIN
675 - ═══════════════════════════════════════════════════════════════ */
676 - .dashboard-main {
677 - display: flex;
678 - flex-direction: column;
679 - gap: 1.5rem;
680 - min-height: 0;
681 - overflow: hidden;
682 - }
683 -
684 - .chart-protagonista {
685 - background: var(--theme-surface);
686 - border-radius: 12px;
687 - padding: 0.75rem;
688 - display: flex;
689 - flex-direction: column;
690 - min-height: 0;
691 - flex: 2;
692 - overflow: hidden;
693 - }
694 -
695 - .ranking-section {
696 - background: var(--theme-surface);
697 - border-radius: 12px;
698 - padding: 0.75rem;
699 - flex: 1;
700 - display: flex;
701 - flex-direction: column;
702 - min-height: 0;
703 - }
704 -
705 - /* ═══════════════════════════════════════════════════════════════
706 - CHART & KPIs
707 - ═══════════════════════════════════════════════════════════════ */
708 - .chart-top-row {
709 - display: flex;
710 - justify-content: space-between;
711 - align-items: center;
712 - margin-bottom: 0.375rem;
713 - }
714 -
715 - .main-title-pills {
716 - display: flex;
717 - gap: 0.375rem;
718 - }
719 -
720 - .pill {
721 - font-family: 'DM Mono', monospace;
722 - font-size: 0.625rem;
723 - padding: 0.125rem 0.5rem;
724 - border-radius: 4px;
725 - letter-spacing: 0.03em;
726 - }
727 -
728 - .pill-nivel {
729 - background: rgba(201, 167, 81, 0.15);
730 - color: var(--theme-accent);
731 - }
732 -
733 - .pill-codigo {
734 - background: var(--theme-borde);
735 - color: var(--theme-texto);
736 - }
737 -
738 - .main-title-name {
739 - font-family: 'DM Serif Display', serif;
740 - font-size: 1.75rem;
741 - font-weight: 400;
742 - margin: 0 0 0.5rem;
743 - line-height: 1.2;
744 - color: var(--theme-titulo);
745 - }
746 -
747 - .chart-controls {
748 - margin-bottom: 0.5rem;
749 - }
750 -
751 - .chart-header {
752 - margin-bottom: 0.25rem;
753 - }
754 -
755 - .chart-title {
756 - font-family: 'DM Mono', monospace;
757 - font-size: 0.6875rem;
758 - color: var(--theme-texto);
759 - opacity: 0.7;
760 - }
761 -
762 - .chart-period {
763 - opacity: 0.5;
764 - }
765 -
766 - .chart-loading {
767 - display: flex;
768 - align-items: center;
769 - justify-content: center;
770 - color: var(--theme-texto);
771 - opacity: 0.5;
772 - }
773 -
774 - .chart-flex-wrapper {
775 - flex: 1;
776 - min-height: 0;
777 - }
778 -
779 - .kpis {
780 - display: grid;
781 - grid-template-columns: repeat(4, 1fr);
782 - gap: 0.5rem;
783 - margin-top: 0.5rem;
784 - padding-top: 0.75rem;
785 - border-top: 1px solid var(--theme-borde);
786 - }
787 -
788 - .kpi {
789 - display: flex;
790 - flex-direction: column;
791 - gap: 0.125rem;
792 - }
793 -
794 - .kpi-value {
795 - font-family: 'DM Mono', monospace;
796 - font-size: 0.9375rem;
797 - font-weight: 600;
798 - color: var(--theme-titulo);
799 - }
800 -
801 - .kpi-label {
802 - font-size: 0.5625rem;
803 - color: var(--theme-texto);
804 - opacity: 0.6;
805 - line-height: 1.3;
806 - }
807 -
808 - /* ═══════════════════════════════════════════════════════════════
809 - RANKING
810 - ═══════════════════════════════════════════════════════════════ */
811 - .ranking-title {
812 - font-size: 1.125rem;
813 - font-weight: 600;
814 - color: var(--theme-titulo);
815 - margin: 0 0 0.25rem;
816 - }
817 -
818 - .ranking-desc {
819 - font-size: 0.75rem;
820 - color: var(--theme-texto);
821 - opacity: 0.6;
822 - margin: 0 0 0.75rem;
823 - }
824 -
825 - .ranking-cards {
826 - display: flex;
827 - flex-direction: column;
828 - gap: 0.25rem;
829 - }
830 -
831 - .ranking-card {
832 - display: flex;
833 - align-items: center;
834 - gap: 0.75rem;
835 - padding: 0.5rem 0.625rem;
836 - border-radius: 8px;
837 - background: var(--theme-borde);
838 - }
839 -
840 - .ranking-position {
841 - font-family: 'DM Mono', monospace;
842 - font-size: 0.6875rem;
843 - color: var(--theme-accent);
844 - font-weight: 600;
845 - min-width: 1.5rem;
846 - }
847 -
848 - .ranking-name {
849 - flex: 1;
850 - font-size: 0.75rem;
851 - color: var(--theme-titulo);
852 - }
853 -
854 - .ranking-pct {
855 - font-family: 'DM Mono', monospace;
856 - font-size: 0.8125rem;
857 - font-weight: 600;
858 - color: var(--theme-titulo);
859 - }
860 -
861 - /* ═══════════════════════════════════════════════════════════════
862 - DARK MODE
863 - ═══════════════════════════════════════════════════════════════ */
864 - :global(html.dark) .context-hint {
865 - color: rgba(255, 255, 255, 0.4);
866 - }
867 -
868 - :global(html.dark) .def-link {
869 - color: var(--theme-accent, #C9A751);
870 - }
871 -</style>
1 -import { supabase } from '$lib/supabase';
2 -import { error } from '@sveltejs/kit';
3 -
4 -// Funciones para derivar jerarquía del código objeto
5 -function getGrupoCode(objeto) {
6 - return objeto.charAt(0) + '0000';
7 -}
8 -
9 -function getSubgrupoCode(objeto) {
10 - return objeto.substring(0, 2) + '000';
11 -}
12 -
13 -function getPartidaCode(objeto) {
14 - return objeto.substring(0, 3) + '00';
15 -}
16 -
17 -function getNivel(objeto) {
18 - if (objeto.endsWith('0000')) return 'grupo';
19 - if (objeto.endsWith('000')) return 'subgrupo';
20 - if (objeto.endsWith('00')) return 'partida';
21 - return 'subpartida';
22 -}
23 -
24 -export async function load({ params }) {
25 - console.time('[SERVER] Total load objeto');
26 - const { codigo } = params;
27 -
28 - console.time('[SERVER] Query clas_objetos');
29 - const { data, error: dbError } = await supabase
30 - .schema('ppto')
31 - .from('clas_objetos')
32 - .select('*')
33 - .eq('objeto', codigo);
34 - console.timeEnd('[SERVER] Query clas_objetos');
35 -
36 - if (dbError || !data || data.length === 0) {
37 - throw error(404, 'Objeto no encontrado');
38 - }
39 -
40 - // Tomar el primer resultado
41 - const objeto = data[0];
42 - const nivel = objeto.nivel || getNivel(codigo);
43 -
44 - // Obtener jerarquía (padres e hijos)
45 - let padres = [];
46 - let hijos = [];
47 -
48 - console.time('[SERVER] Query padres');
49 -
50 - // Buscar padres según nivel
51 - if (nivel === 'subpartida') {
52 - // Padres: grupo, subgrupo, partida
53 - const grupoCode = getGrupoCode(codigo);
54 - const subgrupoCode = getSubgrupoCode(codigo);
55 - const partidaCode = getPartidaCode(codigo);
56 -
57 - const { data: padresData } = await supabase
58 - .schema('ppto')
59 - .from('clas_objetos')
60 - .select('*')
61 - .in('objeto', [grupoCode, subgrupoCode, partidaCode])
62 - .order('objeto');
63 -
64 - if (padresData) padres = padresData;
65 - } else if (nivel === 'partida') {
66 - // Padres: grupo, subgrupo
67 - const grupoCode = getGrupoCode(codigo);
68 - const subgrupoCode = getSubgrupoCode(codigo);
69 -
70 - const { data: padresData } = await supabase
71 - .schema('ppto')
72 - .from('clas_objetos')
73 - .select('*')
74 - .in('objeto', [grupoCode, subgrupoCode])
75 - .order('objeto');
76 -
77 - if (padresData) padres = padresData;
78 - } else if (nivel === 'subgrupo') {
79 - // Padre: grupo
80 - const grupoCode = getGrupoCode(codigo);
81 -
82 - const { data: padresData } = await supabase
83 - .schema('ppto')
84 - .from('clas_objetos')
85 - .select('*')
86 - .eq('objeto', grupoCode);
87 -
88 - if (padresData) padres = padresData;
89 - }
90 - // grupo no tiene padres
91 -
92 - console.timeEnd('[SERVER] Query padres');
93 -
94 - console.time('[SERVER] Query hijos');
95 -
96 - // Buscar hijos según nivel
97 - if (nivel === 'grupo') {
98 - // Hijos: subgrupos que empiecen con el mismo dígito
99 - const prefix = codigo.charAt(0);
100 - const { data: hijosData } = await supabase
101 - .schema('ppto')
102 - .from('clas_objetos')
103 - .select('*')
104 - .eq('nivel', 'subgrupo')
105 - .like('objeto', `${prefix}%`)
106 - .order('objeto');
107 -
108 - hijos = hijosData || [];
109 - } else if (nivel === 'subgrupo') {
110 - // Hijos: partidas que empiecen con los mismos 2 dígitos
111 - const prefix = codigo.substring(0, 2);
112 - const { data: hijosData } = await supabase
113 - .schema('ppto')
114 - .from('clas_objetos')
115 - .select('*')
116 - .eq('nivel', 'partida')
117 - .like('objeto', `${prefix}%`)
118 - .order('objeto');
119 -
120 - hijos = hijosData || [];
121 - } else if (nivel === 'partida') {
122 - // Hijos: subpartidas que empiecen con los mismos 3 dígitos
123 - const prefix = codigo.substring(0, 3);
124 - const { data: hijosData } = await supabase
125 - .schema('ppto')
126 - .from('clas_objetos')
127 - .select('*')
128 - .eq('nivel', 'subpartida')
129 - .like('objeto', `${prefix}%`)
130 - .order('objeto');
131 -
132 - hijos = hijosData || [];
133 - }
134 - // subpartida no tiene hijos
135 -
136 - console.timeEnd('[SERVER] Query hijos');
137 -
138 - console.timeEnd('[SERVER] Total load objeto');
139 - return {
140 - objeto,
141 - padres,
142 - hijos
143 - };
144 -}
1 -<script>
2 - import { onMount } from 'svelte';
3 - import { tweened } from 'svelte/motion';
4 - import { cubicOut } from 'svelte/easing';
5 - import { supabase } from '$lib/supabase';
6 - import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
7 - import BarChart from '$lib/components/objeto/BarChart.svelte';
8 - import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
9 - import { initIndex } from '$lib/stores/searchStore';
10 -
11 - let { data } = $props();
12 - let objetoData = $derived(data.objeto);
13 - let padres = $derived(data.padres);
14 - let hijos = $derived(data.hijos);
15 -
16 - let mounted = $state(false);
17 - let loading = $state(true);
18 - let hoveredYear = $state(null);
19 - let showDefinicionesModal = $state(false);
20 - let showMobileSidebar = $state(false);
21 - let metrica = $state('percapita'); // 'percapita' | 'monto'
22 -
23 - // Parsear descripciones
24 - function parseDescripciones(str) {
25 - if (!str) return [];
26 - try {
27 - const parsed = JSON.parse(str);
28 - return parsed.sort((a, b) => {
29 - const yA = a.rangos?.match(/\d{4}/g) || [];
30 - const yB = b.rangos?.match(/\d{4}/g) || [];
31 - return Math.max(...yB.map(Number), 0) - Math.max(...yA.map(Number), 0);
32 - });
33 - } catch { return []; }
34 - }
35 -
36 - let descripciones = $derived(parseDescripciones(objetoData.descripciones));
37 - let variaciones = $derived(descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion })));
38 -
39 - // Estado selector entidad
40 - let entidades = $state([]);
41 - let selectedEntity = $state(null);
42 - let nEntidades = $derived(data.nEntidades);
43 -
44 - // Datos
45 - let datosAnuales = $state([]);
46 - let datosHistorico = $state(null);
47 - let topEntidadesPorAño = $state({});
48 -
49 - const POBLACION = 12000000;
50 - let objetoCodigo = $derived(objetoData.objeto);
51 - let objetoNivel = $derived(objetoData.nivel);
52 -
53 - let gastoPerCapita = $derived(
54 - datosAnuales.map(d => {
55 - const monto = selectedEntity ? (d.monto ?? d.devengado) : d.total;
56 - const perCapita = d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0);
57 - return {
58 - año: d.gestion,
59 - monto,
60 - perCapita: metrica === 'percapita' ? perCapita : monto
61 - };
62 - })
63 - );
64 -
65 - let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
66 - let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
67 -
68 - let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
69 - let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
70 - let totalPorcentaje = $derived(datosHistorico?.prop || 0);
71 - let ranking = $derived(datosHistorico?.ranking || 0);
72 - let nPares = $derived(datosHistorico?.n_pares || 0);
73 -
74 - let currentData = $derived(
75 - hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
76 - );
77 -
78 - let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
79 - let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
80 - let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
81 - let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
82 - let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
83 - let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
84 - let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
85 - let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
86 - let nivelKey = $derived(objetoData.nivel?.toLowerCase());
87 - let nivelPlural = $derived({ grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' }[nivelKey] || 'objetos');
88 - let nivelLabel = $derived({ grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }[nivelKey] || objetoData.nivel);
89 - let displayRankingLabel = $derived(currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`);
90 -
91 - // Top entidades
92 - let topEntidadesTotal = $derived(
93 - datosHistorico && !selectedEntity ? [
94 - { nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
95 - { nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
96 - { nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
97 - ].filter(e => e.nombre) : []
98 - );
99 - let displayTopEntidades = $derived(
100 - hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
101 - );
102 -
103 - // Tweens
104 - const tw = { duration: 300, easing: cubicOut };
105 - const twMonto = tweened(0, tw);
106 - const twPerCapita = tweened(0, tw);
107 - const twPorcentaje = tweened(0, tw);
108 - const twTop1 = tweened(0, tw);
109 - const twTop2 = tweened(0, tw);
110 - const twTop3 = tweened(0, tw);
111 -
112 - $effect(() => { twMonto.set(displayMonto); });
113 - $effect(() => { twPerCapita.set(displayPerCapita); });
114 - $effect(() => { twPorcentaje.set(displayPorcentaje); });
115 - $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
116 - $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
117 - $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
118 -
119 - function formatMontoLargo(n) {
120 - if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
121 - if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
122 - if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
123 - return n.toLocaleString('es-BO');
124 - }
125 -
126 - function formatPerCapita(n) {
127 - return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
128 - }
129 -
130 - async function loadData() {
131 - loading = true;
132 - if (selectedEntity) {
133 - const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
134 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion');
135 - if (data) {
136 - datosHistorico = data.find(d => d.gestion === 0) || null;
137 - datosAnuales = data.filter(d => d.gestion > 0);
138 - topEntidadesPorAño = {};
139 - }
140 - } else {
141 - const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
142 - .eq('objeto', objetoCodigo).order('gestion');
143 - if (data) {
144 - datosHistorico = data.find(d => d.gestion === 0) || null;
145 - datosAnuales = data.filter(d => d.gestion > 0);
146 - topEntidadesPorAño = {};
147 - datosAnuales.forEach(d => {
148 - topEntidadesPorAño[d.gestion] = [
149 - { nombre: d.top1_entidad, porcentaje: d.top1_pct },
150 - { nombre: d.top2_entidad, porcentaje: d.top2_pct },
151 - { nombre: d.top3_entidad, porcentaje: d.top3_pct },
152 - ].filter(e => e.nombre);
153 - });
154 - }
155 - }
156 - loading = false;
157 - }
158 -
159 - async function loadEntidades() {
160 - const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc')
161 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc');
162 - if (data) entidades = data;
163 - }
164 -
165 - onMount(async () => {
166 - mounted = true;
167 - initIndex();
168 - await Promise.all([loadData(), loadEntidades()]);
169 - });
170 -</script>
171 -
172 -<svelte:head>
173 - <link rel="preconnect" href="https://fonts.googleapis.com" />
174 - <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
175 -</svelte:head>
176 -
177 -<div class="page" class:mounted>
178 - <nav class="page-nav">
179 - <a href="/clasificadores/objeto-gasto" class="back-link">
180 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
181 - <path d="M19 12H5M12 19l-7-7 7-7"/>
182 - </svg>
183 - Clasificador
184 - </a>
185 - </nav>
186 -
187 - <div class="dashboard-layout">
188 - <!-- Sidebar -->
189 - <aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
190 - <!-- Mobile toggle -->
191 - <button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
192 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
193 - <circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
194 - </svg>
195 - <span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
196 - </button>
197 -
198 - <div class="context-card">
199 - <h2 class="context-main-title">Herramientas</h2>
200 -
201 - <!-- Buscar -->
202 - <div class="context-section">
203 - <h3 class="context-subtitle">Buscar</h3>
204 - <p class="context-hint">Buscar otro objeto de gasto</p>
205 - <ObjetoSearch />
206 - </div>
207 -
208 - <div class="context-separator"></div>
209 -
210 - <!-- Navegación -->
211 - {#if padres.length > 0 || hijos.length > 0}
212 - <div class="context-section">
213 - <h3 class="context-subtitle">Navegación</h3>
214 - <p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
215 - <div class="tree">
216 - {#each padres as padre, i}
217 - <a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
218 - <span class="tree-dot"></span>
219 - <span class="tree-code">{padre.objeto}</span>
220 - <span class="tree-name">{padre.desc_objeto}</span>
221 - </a>
222 - {/each}
223 -
224 - <div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
225 - <span class="tree-dot"></span>
226 - <span class="tree-code">{objetoData.objeto}</span>
227 - <span class="tree-name">{objetoData.desc_objeto}</span>
228 - </div>
229 -
230 - {#if hijos.length > 0}
231 - <div class="tree-children" style="--indent: {padres.length + 1}">
232 - {#each hijos as hijo}
233 - <a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
234 - <span class="tree-dot"></span>
235 - <span class="tree-code">{hijo.objeto}</span>
236 - <span class="tree-name">{hijo.desc_objeto}</span>
237 - </a>
238 - {/each}
239 - </div>
240 - {/if}
241 - </div>
242 - </div>
243 -
244 - <div class="context-separator"></div>
245 - {/if}
246 -
247 - <!-- Explorar -->
248 - <div class="context-section">
249 - <h3 class="context-subtitle">Explorar visualizaciones</h3>
250 - <div class="explore-scroll">
251 - <a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
252 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
253 - <rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
254 - </svg>
255 - <span class="explore-title">Comparar dos entidades</span>
256 - <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
257 - </a>
258 - <a href="/clasificadores/objeto-gasto" class="explore-card">
259 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
260 - <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
261 - </svg>
262 - <span class="explore-title">Ranking</span>
263 - <span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
264 - </a>
265 - <a href="/ubicacion" class="explore-card explore-card-disabled">
266 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
267 - <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
268 - </svg>
269 - <span class="explore-title">Mapa</span>
270 - <span class="explore-desc">Dónde se concentra el gasto en {objetoData.desc_objeto?.toLowerCase()}</span>
271 - </a>
272 - <a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
273 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
274 - <circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
275 - </svg>
276 - <span class="explore-title">Peso relativo</span>
277 - <span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
278 - </a>
279 - <a href="/clasificadores/objeto-gasto" class="explore-card">
280 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
281 - <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
282 - </svg>
283 - <span class="explore-title">Clasificador</span>
284 - <span class="explore-desc">Ver todos los objetos de gasto y navegar entre ellos</span>
285 - </a>
286 - </div>
287 - </div>
288 -
289 - <div class="context-separator"></div>
290 -
291 - <!-- Comparabilidad -->
292 - <div class="context-section">
293 - <h3 class="context-subtitle">Comparabilidad</h3>
294 - <div class="vigencia-compact">
295 - <div class="vigencia-status-icon vigencia-full">
296 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
297 - <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
298 - <polyline points="22 4 12 14.01 9 11.01"/>
299 - </svg>
300 - </div>
301 - <div class="vigencia-text">
302 - <span class="vigencia-main">Comparable en todo el período</span>
303 - <span class="vigencia-sub">2005 – 2025</span>
304 - </div>
305 - </div>
306 - </div>
307 -
308 - <div class="context-separator"></div>
309 -
310 - <!-- Definición -->
311 - <div class="context-section">
312 - <h3 class="context-subtitle">Definición</h3>
313 - <button class="def-link" onclick={() => showDefinicionesModal = true}>
314 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
315 - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
316 - </svg>
317 - {#if variaciones.length === 1}
318 - Ver definición oficial
319 - {:else}
320 - Ver {variaciones.length} variaciones en la definición
321 - {/if}
322 - </button>
323 - </div>
324 - </div>
325 - </aside>
326 -
327 - <!-- Main -->
328 - <main class="dashboard-main">
329 - <div class="chart-protagonista">
330 - <!-- Header -->
331 - <div class="chart-top-row">
332 - <div class="main-title-pills">
333 - <span class="pill pill-nivel">{nivelLabel}</span>
334 - <span class="pill pill-codigo">{objetoData.objeto}</span>
335 - </div>
336 - </div>
337 -
338 - <h2 class="main-title-name">{objetoData.desc_objeto}</h2>
339 -
340 - <div class="chart-controls">
341 - <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
342 - </div>
343 -
344 - <div class="chart-header">
345 - <span class="chart-title">
346 - {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
347 - <span class="chart-period">· {displayPeriodo}</span>
348 - </span>
349 - <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
350 - {metrica === 'percapita' ? 'Ver monto real' : 'Ver per cápita'}
351 - </button>
352 - </div>
353 -
354 - {#if loading}
355 - <div class="chart-loading" style="flex: 1; min-height: 100px;">
356 - <span>Cargando...</span>
357 - </div>
358 - {:else}
359 - <div class="chart-flex-wrapper">
360 - <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
361 - </div>
362 - {/if}
363 -
364 - <!-- KPIs -->
365 - <div class="kpis">
366 - <div class="kpi">
367 - <span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
368 - <span class="kpi-label">{displayMontoLabel}</span>
369 - </div>
370 - <div class="kpi">
371 - <span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
372 - <span class="kpi-label">{displayPerCapitaLabel}</span>
373 - </div>
374 - <div class="kpi">
375 - <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
376 - <span class="kpi-label">{displayPorcentajeLabel}</span>
377 - </div>
378 - <div class="kpi">
379 - <span class="kpi-value">{displayRanking}</span>
380 - <span class="kpi-label">{displayRankingLabel}</span>
381 - </div>
382 - </div>
383 - </div>
384 -
385 - {#if !selectedEntity && displayTopEntidades.length > 0}
386 - <div class="ranking-section">
387 - <h3 class="ranking-title">Concentración del gasto</h3>
388 - <p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
389 - <div class="ranking-cards">
390 - {#each displayTopEntidades as ent, i}
391 - {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
392 - <div class="ranking-card">
393 - <span class="ranking-position">#{i + 1}</span>
394 - <span class="ranking-name">{ent.nombre}</span>
395 - <span class="ranking-pct">{pct.toFixed(1)}%</span>
396 - </div>
397 - {/each}
398 - </div>
399 - </div>
400 - {/if}
401 - </main>
402 - </div>
403 -
404 - <!-- Modal de definiciones -->
405 - {#if showDefinicionesModal}
406 - <div class="modal-overlay" onclick={() => showDefinicionesModal = false}>
407 - <div class="modal-content" onclick={(e) => e.stopPropagation()}>
408 - <div class="modal-header">
409 - <h3>Definiciones de "{objetoData.desc_objeto}"</h3>
410 - <button class="modal-close" onclick={() => showDefinicionesModal = false}>
411 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
412 - <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
413 - </svg>
414 - </button>
415 - </div>
416 - <div class="modal-body">
417 - {#each variaciones as v, i}
418 - <div class="def-item" class:def-current={i === 0}>
419 - <div class="def-header">
420 - <span class="def-period">{v.rango}</span>
421 - {#if i === 0}
422 - <span class="def-badge">Vigente</span>
423 - {/if}
424 - </div>
425 - <p class="def-text">{v.texto}</p>
426 - </div>
427 - {/each}
428 - </div>
429 - </div>
430 - </div>
431 - {/if}
432 -</div>
433 -
434 -<style>
435 - /* ═══════════════════════════════════════════════════════════════
436 - PAGE
437 - ═══════════════════════════════════════════════════════════════ */
438 - .page {
439 - min-height: 100vh;
440 - width: 100%;
441 - max-width: 100%;
442 - overflow-x: hidden;
443 - background: var(--theme-body);
444 - color: var(--theme-titulo);
445 - font-family: var(--font-sans), -apple-system, sans-serif;
446 - opacity: 0;
447 - transition: opacity 0.4s ease;
448 - }
449 - .page.mounted { opacity: 1; }
450 -
451 - :global(html:not(.dark)) .page {
452 - background: #f5f5f7;
453 - }
454 -
455 - /* ═══════════════════════════════════════════════════════════════
456 - NAV
457 - ═══════════════════════════════════════════════════════════════ */
458 - .page-nav {
459 - padding: 2rem 2rem 0.5rem;
460 - max-width: 1400px;
461 - margin: 0 auto;
462 - }
463 -
464 - .back-link {
465 - display: inline-flex;
466 - align-items: center;
467 - gap: 0.375rem;
468 - font-family: 'DM Mono', monospace;
469 - font-size: 0.6875rem;
470 - color: var(--theme-texto);
471 - text-decoration: none;
472 - transition: color 0.2s;
473 - }
474 - .back-link:hover { color: var(--theme-accent); }
475 -
476 - /* ═══════════════════════════════════════════════════════════════
477 - LAYOUT
478 - ═══════════════════════════════════════════════════════════════ */
479 - .dashboard-layout {
480 - display: grid;
481 - grid-template-columns: minmax(200px, 30%) 1fr;
482 - gap: 1.5rem;
483 - max-width: 1400px;
484 - margin: 0 auto;
485 - padding: 30px 2rem;
486 - height: calc(100vh - 4rem);
487 - overflow: hidden;
488 - }
489 -
490 - /* ═══════════════════════════════════════════════════════════════
491 - SIDEBAR
492 - ═══════════════════════════════════════════════════════════════ */
493 - .dashboard-context {
494 - display: flex;
495 - flex-direction: column;
496 - min-height: 0;
497 - }
498 -
499 - .context-card {
500 - background: var(--theme-surface);
501 - border-radius: 12px;
502 - padding: 0.75rem;
503 - display: flex;
504 - flex-direction: column;
505 - gap: 8px;
506 - flex: 1;
507 - min-height: 0;
508 - overflow-y: auto;
509 - scrollbar-width: thin;
510 - }
511 -
512 - .context-card::-webkit-scrollbar { width: 4px; }
513 - .context-card::-webkit-scrollbar-thumb {
514 - background: rgba(128, 128, 128, 0.3);
515 - border-radius: 2px;
516 - }
517 -
518 - .context-main-title {
519 - font-size: 1rem;
520 - font-weight: 600;
521 - color: var(--theme-titulo);
522 - margin: 0;
523 - line-height: 1.2;
524 - text-transform: uppercase;
525 - letter-spacing: 0.05em;
526 - opacity: 0.5;
527 - }
528 -
529 - .context-subtitle {
530 - font-family: 'Qanelas', var(--font-sans);
531 - font-size: 0.875rem;
532 - font-weight: 700;
533 - color: var(--theme-titulo);
534 - margin: 0;
535 - }
536 -
537 - .context-hint {
538 - font-size: 0.75rem;
539 - color: var(--theme-texto);
540 - opacity: 0.7;
541 - margin: 0;
542 - line-height: 1.4;
543 - }
544 -
545 - .context-section {
546 - display: flex;
547 - flex-direction: column;
548 - gap: 0.5rem;
549 - }
550 -
551 - .context-separator {
552 - border-top: 1px solid var(--theme-borde);
553 - margin: 0.25rem 0;
554 - }
555 -
556 - /* ═══════════════════════════════════════════════════════════════
557 - TREE
558 - ═══════════════════════════════════════════════════════════════ */
559 - .tree {
560 - font-size: 0.75rem;
561 - line-height: 1.6;
562 - }
563 -
564 - .tree-node {
565 - display: flex;
566 - align-items: center;
567 - gap: 0.375rem;
568 - padding: 0.375rem 0.5rem;
569 - margin-left: calc(var(--indent, 0) * 1rem);
570 - border-left: 2px solid transparent;
571 - border-radius: 0 6px 6px 0;
572 - text-decoration: none;
573 - color: var(--theme-texto);
574 - transition: all 0.15s;
575 - }
576 - .tree-node:hover { color: var(--theme-accent); }
577 -
578 - .tree-node-current {
579 - color: var(--theme-titulo);
580 - font-weight: 600;
581 - border-left-color: var(--theme-accent);
582 - background: rgba(201, 167, 81, 0.1);
583 - }
584 -
585 - .tree-children {
586 - margin-left: calc(var(--indent, 0) * 1rem);
587 - }
588 -
589 - .tree-node-child {
590 - padding-left: 0.75rem;
591 - }
592 -
593 - .tree-dot {
594 - width: 4px;
595 - height: 4px;
596 - border-radius: 50%;
597 - background: currentColor;
598 - flex-shrink: 0;
599 - opacity: 0.4;
600 - }
601 -
602 - .tree-code {
603 - font-family: 'DM Mono', monospace;
604 - font-size: 0.6875rem;
605 - min-width: 2.5rem;
606 - opacity: 0.6;
607 - }
608 -
609 - .tree-name {
610 - font-size: 0.6875rem;
611 - white-space: nowrap;
612 - overflow: hidden;
613 - text-overflow: ellipsis;
614 - }
615 -
616 - /* ═══════════════════════════════════════════════════════════════
617 - EXPLORE
618 - ═══════════════════════════════════════════════════════════════ */
619 - .explore-scroll {
620 - display: flex;
621 - gap: 8px;
622 - overflow-x: auto;
623 - scrollbar-width: none;
624 - padding-bottom: 2px;
625 - }
626 - .explore-scroll::-webkit-scrollbar { display: none; }
627 -
628 - .explore-card {
629 - position: relative;
630 - display: flex;
631 - flex-direction: column;
632 - justify-content: flex-start;
633 - padding: 0.375rem 0.5rem;
634 - width: 120px;
635 - height: 95px;
636 - border-radius: 8px;
637 - background: var(--theme-borde);
638 - text-decoration: none;
639 - color: var(--theme-texto);
640 - transition: all 0.15s;
641 - flex-shrink: 0;
642 - overflow: hidden;
643 - }
644 - .explore-card:hover {
645 - background: rgba(201, 167, 81, 0.15);
646 - color: var(--theme-accent);
647 - }
648 - .explore-card:hover .explore-watermark {
649 - opacity: 0.18;
650 - color: var(--theme-accent);
651 - }
652 - .explore-card:hover .explore-title {
653 - color: var(--theme-accent);
654 - }
655 -
656 - .explore-watermark {
657 - position: absolute;
658 - bottom: -4px;
659 - right: -4px;
660 - width: 44px;
661 - height: 44px;
662 - opacity: 0.15;
663 - pointer-events: none;
664 - color: var(--theme-accent, #C9A751);
665 - }
666 -
667 - .explore-title {
668 - position: relative;
669 - font-size: 0.75rem;
670 - font-weight: 600;
671 - line-height: 1.3;
672 - color: var(--theme-titulo);
673 - z-index: 1;
674 - }
675 -
676 - .explore-desc {
677 - position: relative;
678 - font-size: 0.625rem;
679 - line-height: 1.35;
680 - opacity: 0.6;
681 - margin-top: 3px;
682 - z-index: 1;
683 - }
684 -
685 - .explore-card-disabled {
686 - opacity: 0.3;
687 - pointer-events: none;
688 - }
689 -
690 - /* ═══════════════════════════════════════════════════════════════
691 - VIGENCIA
692 - ═══════════════════════════════════════════════════════════════ */
693 - .vigencia-compact {
694 - display: flex;
695 - align-items: center;
696 - gap: 0.75rem;
697 - }
698 -
699 - .vigencia-status-icon {
700 - width: 32px;
701 - height: 32px;
702 - border-radius: 8px;
703 - display: flex;
704 - align-items: center;
705 - justify-content: center;
706 - flex-shrink: 0;
707 - }
708 -
709 - .vigencia-status-icon.vigencia-full {
710 - background: rgba(74, 158, 107, 0.15);
711 - color: #4A9E6B;
712 - }
713 -
714 - .vigencia-text {
715 - display: flex;
716 - flex-direction: column;
717 - gap: 0.125rem;
718 - }
719 -
720 - .vigencia-main {
721 - font-size: 0.75rem;
722 - font-weight: 500;
723 - color: var(--theme-titulo);
724 - }
725 -
726 - .vigencia-sub {
727 - font-size: 0.625rem;
728 - color: var(--theme-texto);
729 - opacity: 0.5;
730 - }
731 -
732 - /* ═══════════════════════════════════════════════════════════════
733 - DEFINICIÓN
734 - ═══════════════════════════════════════════════════════════════ */
735 - .def-link {
736 - display: flex;
737 - align-items: center;
738 - gap: 0.375rem;
739 - font-size: 0.75rem;
740 - color: var(--theme-accent, #C9A751);
741 - background: none;
742 - border: none;
743 - padding: 0;
744 - cursor: pointer;
745 - transition: opacity 0.15s;
746 - }
747 - .def-link:hover { opacity: 0.7; }
748 - .def-link svg { flex-shrink: 0; opacity: 0.8; }
749 -
750 - /* ═══════════════════════════════════════════════════════════════
751 - MAIN
752 - ═══════════════════════════════════════════════════════════════ */
753 - .dashboard-main {
754 - display: flex;
755 - flex-direction: column;
756 - gap: 1.5rem;
757 - min-height: 0;
758 - overflow: hidden;
759 - }
760 -
761 - .chart-protagonista {
762 - background: var(--theme-surface);
763 - border-radius: 12px;
764 - padding: 0.75rem;
765 - display: flex;
766 - flex-direction: column;
767 - min-height: 0;
768 - flex: 2;
769 - overflow: hidden;
770 - }
771 -
772 - .ranking-section {
773 - background: var(--theme-surface);
774 - border-radius: 12px;
775 - padding: 0.75rem;
776 - flex: 1;
777 - display: flex;
778 - flex-direction: column;
779 - min-height: 0;
780 - }
781 -
782 - /* ═══════════════════════════════════════════════════════════════
783 - CHART & KPIs
784 - ═══════════════════════════════════════════════════════════════ */
785 - .chart-top-row {
786 - display: flex;
787 - justify-content: space-between;
788 - align-items: center;
789 - margin-bottom: 0.375rem;
790 - }
791 -
792 - .main-title-pills {
793 - display: flex;
794 - gap: 0.375rem;
795 - }
796 -
797 - .pill {
798 - font-family: 'DM Mono', monospace;
799 - font-size: 0.625rem;
800 - padding: 0.125rem 0.5rem;
801 - border-radius: 4px;
802 - letter-spacing: 0.03em;
803 - }
804 -
805 - .pill-nivel {
806 - background: rgba(201, 167, 81, 0.15);
807 - color: var(--theme-accent);
808 - }
809 -
810 - .pill-codigo {
811 - background: var(--theme-borde);
812 - color: var(--theme-texto);
813 - }
814 -
815 - .main-title-name {
816 - font-family: 'DM Serif Display', serif;
817 - font-size: 2rem;
818 - font-weight: 400;
819 - margin: 0 0 0.5rem;
820 - line-height: 1.15;
821 - color: var(--theme-titulo);
822 - }
823 -
824 - .chart-controls {
825 - margin-bottom: 0.5rem;
826 - }
827 -
828 - .chart-header {
829 - display: flex;
830 - align-items: center;
831 - justify-content: space-between;
832 - margin-bottom: 0.25rem;
833 - }
834 -
835 - .chart-title {
836 - font-family: 'DM Mono', monospace;
837 - font-size: 0.6875rem;
838 - color: var(--theme-texto);
839 - opacity: 0.7;
840 - }
841 -
842 - .chart-period {
843 - opacity: 0.5;
844 - }
845 -
846 - .metrica-toggle {
847 - font-family: 'DM Mono', monospace;
848 - font-size: 0.625rem;
849 - color: var(--theme-accent);
850 - background: rgba(201, 167, 81, 0.1);
851 - border: 1px solid rgba(201, 167, 81, 0.2);
852 - padding: 0.2rem 0.5rem;
853 - border-radius: 4px;
854 - cursor: pointer;
855 - transition: all 0.15s;
856 - }
857 -
858 - .metrica-toggle:hover {
859 - background: rgba(201, 167, 81, 0.2);
860 - }
861 -
862 - .chart-loading {
863 - display: flex;
864 - align-items: center;
865 - justify-content: center;
866 - color: var(--theme-texto);
867 - opacity: 0.5;
868 - }
869 -
870 - .chart-flex-wrapper {
871 - flex: 1;
872 - min-height: 0;
873 - }
874 -
875 - .kpis {
876 - display: grid;
877 - grid-template-columns: repeat(4, 1fr);
878 - gap: 0.5rem;
879 - margin-top: 0.5rem;
880 - padding-top: 0.75rem;
881 - border-top: 1px solid var(--theme-borde);
882 - }
883 -
884 - .kpi {
885 - display: flex;
886 - flex-direction: column;
887 - align-items: center;
888 - gap: 0.125rem;
889 - text-align: center;
890 - }
891 -
892 - .kpi-value {
893 - font-family: 'Qanelas', var(--font-sans);
894 - font-size: 1rem;
895 - font-weight: 600;
896 - color: var(--theme-titulo);
897 - }
898 -
899 - .kpi-label {
900 - font-size: 0.625rem;
901 - color: var(--theme-texto);
902 - opacity: 0.5;
903 - line-height: 1.35;
904 - }
905 -
906 - /* ═══════════════════════════════════════════════════════════════
907 - RANKING
908 - ═══════════════════════════════════════════════════════════════ */
909 - .ranking-title {
910 - font-size: 1rem;
911 - font-weight: 600;
912 - color: var(--theme-titulo);
913 - margin: 0 0 0.25rem;
914 - }
915 -
916 - .ranking-desc {
917 - font-size: 0.6875rem;
918 - color: var(--theme-texto);
919 - opacity: 0.5;
920 - margin: 0 0 0.5rem;
921 - }
922 -
923 - .ranking-cards {
924 - display: flex;
925 - flex-direction: column;
926 - gap: 0.25rem;
927 - }
928 -
929 - .ranking-card {
930 - display: flex;
931 - align-items: center;
932 - gap: 0.75rem;
933 - padding: 0.5rem 0.625rem;
934 - border-radius: 8px;
935 - background: rgba(128, 128, 128, 0.06);
936 - }
937 -
938 - .ranking-position {
939 - font-family: 'DM Mono', monospace;
940 - font-size: 0.6875rem;
941 - color: var(--theme-accent);
942 - font-weight: 600;
943 - min-width: 1.5rem;
944 - }
945 -
946 - .ranking-name {
947 - flex: 1;
948 - font-size: 0.8125rem;
949 - color: var(--theme-titulo);
950 - white-space: nowrap;
951 - overflow: hidden;
952 - text-overflow: ellipsis;
953 - }
954 -
955 - .ranking-pct {
956 - font-family: 'DM Mono', monospace;
957 - font-size: 0.875rem;
958 - font-weight: 600;
959 - color: var(--theme-titulo);
960 - }
961 -
962 - /* ═══════════════════════════════════════════════════════════════
963 - MODAL
964 - ═══════════════════════════════════════════════════════════════ */
965 - .modal-overlay {
966 - position: fixed;
967 - inset: 0;
968 - background: rgba(0, 0, 0, 0.6);
969 - backdrop-filter: blur(4px);
970 - display: flex;
971 - align-items: center;
972 - justify-content: center;
973 - z-index: 1000;
974 - padding: 1rem;
975 - }
976 -
977 - .modal-content {
978 - background: var(--theme-surface);
979 - border-radius: 16px;
980 - max-width: 560px;
981 - width: 100%;
982 - max-height: 80vh;
983 - overflow: hidden;
984 - display: flex;
985 - flex-direction: column;
986 - box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
987 - }
988 -
989 - .modal-header {
990 - display: flex;
991 - align-items: center;
992 - justify-content: space-between;
993 - padding: 1.25rem 1.5rem;
994 - border-bottom: 1px solid var(--theme-borde);
995 - }
996 -
997 - .modal-header h3 {
998 - font-size: 1rem;
999 - font-weight: 600;
1000 - color: var(--theme-titulo);
1001 - margin: 0;
1002 - }
1003 -
1004 - .modal-close {
1005 - color: var(--theme-texto);
1006 - background: transparent;
1007 - border: none;
1008 - padding: 0.25rem;
1009 - cursor: pointer;
1010 - border-radius: 6px;
1011 - transition: background 0.2s;
1012 - }
1013 -
1014 - .modal-close:hover {
1015 - background: var(--theme-borde);
1016 - }
1017 -
1018 - .modal-body {
1019 - padding: 1.5rem;
1020 - overflow-y: auto;
1021 - display: flex;
1022 - flex-direction: column;
1023 - gap: 1rem;
1024 - }
1025 -
1026 - .def-item {
1027 - padding: 1rem;
1028 - background: var(--theme-body);
1029 - border-radius: 10px;
1030 - border-left: 3px solid var(--theme-borde);
1031 - }
1032 -
1033 - .def-current {
1034 - border-left-color: #6B9FD4;
1035 - }
1036 -
1037 - .def-header {
1038 - display: flex;
1039 - align-items: center;
1040 - gap: 0.5rem;
1041 - margin-bottom: 0.5rem;
1042 - }
1043 -
1044 - .def-period {
1045 - font-family: 'DM Mono', monospace;
1046 - font-size: 0.75rem;
1047 - font-weight: 500;
1048 - color: #6B9FD4;
1049 - }
1050 -
1051 - .def-badge {
1052 - font-size: 0.625rem;
1053 - font-weight: 600;
1054 - text-transform: uppercase;
1055 - letter-spacing: 0.05em;
1056 - color: white;
1057 - background: #4A9E6B;
1058 - padding: 0.125rem 0.5rem;
1059 - border-radius: 4px;
1060 - }
1061 -
1062 - .def-text {
1063 - font-size: 0.875rem;
1064 - color: var(--theme-texto);
1065 - line-height: 1.6;
1066 - margin: 0;
1067 - }
1068 -
1069 - :global(html.dark) .def-current {
1070 - border-left-color: var(--theme-accent);
1071 - }
1072 -
1073 - :global(html.dark) .def-period {
1074 - color: var(--theme-accent);
1075 - }
1076 -
1077 - /* ═══════════════════════════════════════════════════════════════
1078 - MOBILE SIDEBAR
1079 - ═══════════════════════════════════════════════════════════════ */
1080 - .mobile-sidebar-toggle {
1081 - display: none;
1082 - }
1083 -
1084 - @media (max-width: 900px) {
1085 - .dashboard-layout {
1086 - grid-template-columns: 1fr;
1087 - gap: 0.5rem;
1088 - height: auto;
1089 - overflow: visible;
1090 - padding: 1.5rem 1rem 2rem;
1091 - align-content: center;
1092 - min-height: calc(100vh - 4rem);
1093 - }
1094 -
1095 - .dashboard-main {
1096 - overflow: visible;
1097 - }
1098 -
1099 - .chart-protagonista {
1100 - flex: none;
1101 - }
1102 -
1103 - .ranking-section {
1104 - flex: none;
1105 - }
1106 -
1107 - .mobile-sidebar-toggle {
1108 - display: flex;
1109 - align-items: center;
1110 - gap: 0.5rem;
1111 - padding: 0.625rem 1rem;
1112 - background: var(--theme-surface);
1113 - border: 1px solid var(--theme-borde);
1114 - border-radius: 10px;
1115 - color: var(--theme-titulo);
1116 - font-family: 'Qanelas', var(--font-sans);
1117 - font-size: 0.8125rem;
1118 - font-weight: 500;
1119 - cursor: pointer;
1120 - transition: all 0.2s;
1121 - margin-bottom: 0.25rem;
1122 - width: 100%;
1123 - }
1124 -
1125 - .mobile-sidebar-toggle:hover {
1126 - background: var(--theme-surface-hover);
1127 - }
1128 -
1129 - .mobile-sidebar-toggle svg {
1130 - color: var(--theme-texto);
1131 - opacity: 0.6;
1132 - }
1133 -
1134 - .dashboard-context .context-card {
1135 - display: none;
1136 - }
1137 -
1138 - .dashboard-context.mobile-open .context-card {
1139 - display: flex;
1140 - animation: slideDown 0.25s ease;
1141 - }
1142 -
1143 - @keyframes slideDown {
1144 - from { opacity: 0; transform: translateY(-8px); }
1145 - to { opacity: 1; transform: translateY(0); }
1146 - }
1147 -
1148 - .context-card {
1149 - height: auto;
1150 - overflow-y: visible;
1151 - }
1152 -
1153 - .page-nav {
1154 - padding: 0.75rem 1rem;
1155 - }
1156 -
1157 - .kpis {
1158 - grid-template-columns: repeat(2, 1fr);
1159 - }
1160 - }
1161 -
1162 - @media (max-width: 480px) {
1163 - .dashboard-layout {
1164 - padding: 0.5rem;
1165 - }
1166 -
1167 - .page-nav {
1168 - padding: 0.5rem;
1169 - }
1170 -
1171 - .chart-protagonista,
1172 - .ranking-section,
1173 - .context-card {
1174 - padding: 0.5rem;
1175 - border-radius: 8px;
1176 - }
1177 -
1178 - .main-title-name {
1179 - font-size: 1.375rem;
1180 - }
1181 -
1182 - .chart-header {
1183 - flex-direction: column;
1184 - align-items: flex-start;
1185 - gap: 0.25rem;
1186 - }
1187 -
1188 - .kpis {
1189 - grid-template-columns: repeat(2, 1fr);
1190 - gap: 0.375rem;
1191 - }
1192 -
1193 - .kpi-value {
1194 - font-size: 0.8125rem;
1195 - }
1196 -
1197 - .kpi-label {
1198 - font-size: 0.5625rem;
1199 - }
1200 -
1201 - .explore-scroll {
1202 - gap: 6px;
1203 - }
1204 -
1205 - .explore-card {
1206 - width: 100px;
1207 - height: 80px;
1208 - }
1209 -
1210 - .explore-title {
1211 - font-size: 0.625rem;
1212 - }
1213 -
1214 - .explore-desc {
1215 - font-size: 0.5rem;
1216 - }
1217 -
1218 - .ranking-title {
1219 - font-size: 0.875rem;
1220 - }
1221 -
1222 - .ranking-name {
1223 - font-size: 0.6875rem;
1224 - }
1225 -
1226 - .ranking-pct {
1227 - font-size: 0.75rem;
1228 - }
1229 -
1230 - .modal-content {
1231 - margin: 0.5rem;
1232 - max-height: 90vh;
1233 - }
1234 -
1235 - .tree-name {
1236 - max-width: 150px;
1237 - }
1238 - }
1239 -
1240 - /* ═══════════════════════════════════════════════════════════════
1241 - DARK MODE
1242 - ═══════════════════════════════════════════════════════════════ */
1243 - :global(html.dark) .context-hint {
1244 - color: rgba(255, 255, 255, 0.5);
1245 - }
1246 -
1247 - :global(html.dark) .def-link {
1248 - color: var(--theme-accent, #C9A751);
1249 - }
1250 -</style>
1 -<script>
2 - import { onMount } from 'svelte';
3 - import * as d3 from 'd3';
4 -
5 - let mapaData = $state(null);
6 - let boliviaPath = $state('');
7 - let deptoPath = $state('');
8 - let muniPath = $state('');
9 - let error = $state('');
10 - let debug = $state('');
11 -
12 - onMount(async () => {
13 - try {
14 - const res = await fetch('/mapa.json');
15 - mapaData = await res.json();
16 -
17 - debug = `bolivia: ${mapaData.bolivia.features.length} features, `;
18 - debug += `deptos: ${mapaData.departamentos.features.length}, `;
19 - debug += `munis: ${mapaData.municipios.features.length}`;
20 -
21 - // Quitar CRS y corregir winding order
22 - delete mapaData.bolivia.crs;
23 - delete mapaData.departamentos.crs;
24 - delete mapaData.municipios.crs;
25 -
26 - // Corregir winding order: invertir todos los anillos
27 - function fixWinding(geojson) {
28 - const fix = (coords, isHole) => {
29 - // Para geo coords: exterior debe ser counterclockwise (area < 0 en D3)
30 - // Simplemente invertimos todos los anillos exteriores
31 - coords.forEach((ring, i) => {
32 - if (i === 0) ring.reverse(); // exterior
33 - // holes quedan como están
34 - });
35 - };
36 - for (const feat of geojson.features || [geojson]) {
37 - const geom = (feat.geometry || feat);
38 - if (geom.type === 'Polygon') {
39 - fix(geom.coordinates);
40 - } else if (geom.type === 'MultiPolygon') {
41 - geom.coordinates.forEach(poly => fix(poly));
42 - }
43 - }
44 - }
45 -
46 - fixWinding(mapaData.bolivia);
47 - fixWinding(mapaData.departamentos);
48 - fixWinding(mapaData.municipios);
49 -
50 - const bounds = d3.geoBounds(mapaData.bolivia);
51 - debug += ` | bounds: ${JSON.stringify(bounds)}`;
52 -
53 - const projection = d3.geoMercator().fitSize([380, 480], mapaData.bolivia);
54 - const pathGen = d3.geoPath(projection);
55 -
56 - boliviaPath = pathGen(mapaData.bolivia) || '';
57 - debug += ` | boliviaPath length: ${boliviaPath.length}`;
58 -
59 - deptoPath = mapaData.departamentos.features.map(f => pathGen(f)).join(' ');
60 - debug += ` | deptoPath length: ${deptoPath.length}`;
61 -
62 - const muni = mapaData.municipios.features[0];
63 - muniPath = pathGen(muni) || '';
64 - debug += ` | muni code: ${muni.properties.codigo}`;
65 -
66 - } catch (e) {
67 - error = e.message;
68 - }
69 - });
70 -</script>
71 -
72 -<div style="padding: 2rem; background: #1C1C1A; color: #F5F0E8; min-height: 100vh;">
73 - <h1>Test Mapa</h1>
74 - <p style="font-size: 12px; color: #888;">{debug}</p>
75 - {#if error}
76 - <p style="color: red;">{error}</p>
77 - {/if}
78 -
79 - <svg width="400" height="500" viewBox="0 0 400 500" style="border: 1px solid #333;">
80 - {#if boliviaPath}
81 - <path d={boliviaPath} fill="none" stroke="#555" stroke-width="1" />
82 - {/if}
83 - {#if deptoPath}
84 - <path d={deptoPath} fill="rgba(255,255,255,0.1)" stroke="#777" stroke-width="0.5" />
85 - {/if}
86 - {#if muniPath}
87 - <path d={muniPath} fill="#D4A574" stroke="#D4A574" stroke-width="1" />
88 - {/if}
89 - </svg>
90 -</div>
1 -import { supabase } from '$lib/supabase'; 1 +export const ssr = false;
2 2
3 export async function load({ fetch }) { 3 export async function load({ fetch }) {
4 - // Cargar resumen y población en paralelo 4 + const pobRes = await fetch('/poblacion.csv').then(r => r.text());
5 - const [resumenRes, pobRes] = await Promise.all([
6 - supabase
7 - .schema('ppto')
8 - .from('entidad_resumen')
9 - .select('codigo, desc, gestion, devengado, ranking')
10 - .eq('tipo', 'gastos')
11 - .eq('tipo_codigo', 'municipio_ubigeo')
12 - .eq('gestion', 2025)
13 - .order('devengado', { ascending: false })
14 - .limit(500),
15 -
16 - fetch('/poblacion.csv').then(r => r.text())
17 - ]);
18 5
19 const poblacionMap = {}; 6 const poblacionMap = {};
20 pobRes.split('\n').slice(1).forEach(line => { 7 pobRes.split('\n').slice(1).forEach(line => {
...@@ -27,7 +14,7 @@ export async function load({ fetch }) { ...@@ -27,7 +14,7 @@ export async function load({ fetch }) {
27 const gestiones = [2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]; 14 const gestiones = [2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025];
28 15
29 return { 16 return {
30 - resumen: resumenRes.data || [], 17 + resumen: [],
31 poblacionMap, 18 poblacionMap,
32 gestiones, 19 gestiones,
33 gestionInicial: 2025 20 gestionInicial: 2025
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
4 import { goto } from '$app/navigation'; 4 import { goto } from '$app/navigation';
5 import { page } from '$app/stores'; 5 import { page } from '$app/stores';
6 import * as d3 from 'd3'; 6 import * as d3 from 'd3';
7 - import { supabase } from '$lib/supabase';
8 import { mapaCache } from '$lib/stores/mapaCache'; 7 import { mapaCache } from '$lib/stores/mapaCache';
9 8
10 let { data } = $props(); 9 let { data } = $props();
...@@ -38,6 +37,22 @@ ...@@ -38,6 +37,22 @@
38 let clasSearchVal = $state(''); 37 let clasSearchVal = $state('');
39 let clasSearchResults = $state([]); 38 let clasSearchResults = $state([]);
40 let clasSearchFocused = $state(false); 39 let clasSearchFocused = $state(false);
40 + let clasSelectedIdx = $state(-1);
41 +
42 + function handleClasKeydown(e) {
43 + if (e.key === 'ArrowDown') {
44 + e.preventDefault();
45 + clasSelectedIdx = clasSelectedIdx < clasSearchResults.length - 1 ? clasSelectedIdx + 1 : 0;
46 + } else if (e.key === 'ArrowUp') {
47 + e.preventDefault();
48 + clasSelectedIdx = clasSelectedIdx > 0 ? clasSelectedIdx - 1 : clasSearchResults.length - 1;
49 + } else if (e.key === 'Enter' && clasSelectedIdx >= 0) {
50 + e.preventDefault();
51 + seleccionarClasificador(clasSearchResults[clasSelectedIdx]);
52 + } else if (e.key === 'Escape') {
53 + clasSearchFocused = false;
54 + }
55 + }
41 let clasSearchLoading = $state(false); 56 let clasSearchLoading = $state(false);
42 let clasSeleccionado = $state(null); 57 let clasSeleccionado = $state(null);
43 let clasDebounce; 58 let clasDebounce;
...@@ -89,6 +104,7 @@ ...@@ -89,6 +104,7 @@
89 104
90 function handleClasInput(e) { 105 function handleClasInput(e) {
91 clasSearchVal = e.target.value; 106 clasSearchVal = e.target.value;
107 + clasSelectedIdx = -1;
92 clearTimeout(clasDebounce); 108 clearTimeout(clasDebounce);
93 if (clasSearchVal.length >= 2) { 109 if (clasSearchVal.length >= 2) {
94 clasDebounce = setTimeout(() => buscarClasificador(clasSearchVal), 200); 110 clasDebounce = setTimeout(() => buscarClasificador(clasSearchVal), 200);
...@@ -362,20 +378,9 @@ ...@@ -362,20 +378,9 @@
362 return; 378 return;
363 } 379 }
364 380
365 - // Modo total: cargar desde Supabase 381 + // Modo total: datos no disponibles sin Supabase
366 - if (!resumenCache[gestion]) { 382 + // TODO: implementar endpoint API para resumen por municipio
367 - const { data: rows } = await supabase 383 + resumen = [];
368 - .schema('ppto')
369 - .from('entidad_resumen')
370 - .select('codigo, desc, gestion, devengado, ranking')
371 - .eq('tipo', 'gastos')
372 - .eq('tipo_codigo', 'municipio_ubigeo')
373 - .eq('gestion', gestion)
374 - .order('devengado', { ascending: false })
375 - .limit(500);
376 - resumenCache[gestion] = rows || [];
377 - }
378 - resumen = resumenCache[gestion];
379 poblacionMap = await getPoblacionAno(gestion); 384 poblacionMap = await getPoblacionAno(gestion);
380 } 385 }
381 386
...@@ -588,6 +593,7 @@ ...@@ -588,6 +593,7 @@
588 type="text" 593 type="text"
589 value={clasSearchVal} 594 value={clasSearchVal}
590 oninput={handleClasInput} 595 oninput={handleClasInput}
596 + onkeydown={handleClasKeydown}
591 onfocus={() => clasSearchFocused = true} 597 onfocus={() => clasSearchFocused = true}
592 onblur={() => setTimeout(() => clasSearchFocused = false, 150)} 598 onblur={() => setTimeout(() => clasSearchFocused = false, 150)}
593 placeholder="Buscar {clasificadorSeleccionado === 'objeto' ? 'partida' : clasificadorSeleccionado === 'finfun' ? 'finalidad o función' : 'sector'}..." 599 placeholder="Buscar {clasificadorSeleccionado === 'objeto' ? 'partida' : clasificadorSeleccionado === 'finfun' ? 'finalidad o función' : 'sector'}..."
...@@ -599,8 +605,8 @@ ...@@ -599,8 +605,8 @@
599 {:else if clasSearchResults.length === 0} 605 {:else if clasSearchResults.length === 0}
600 <div class="objeto-search-msg">Sin resultados</div> 606 <div class="objeto-search-msg">Sin resultados</div>
601 {:else} 607 {:else}
602 - {#each clasSearchResults as result} 608 + {#each clasSearchResults as result, i}
603 - <button class="objeto-search-item" onclick={() => seleccionarClasificador(result)}> 609 + <button class="objeto-search-item" class:active={clasSelectedIdx === i} onclick={() => seleccionarClasificador(result)}>
604 <span class="objeto-search-code">{result.codigo}</span> 610 <span class="objeto-search-code">{result.codigo}</span>
605 <span class="objeto-search-name">{@html result.highlight}</span> 611 <span class="objeto-search-name">{@html result.highlight}</span>
606 </button> 612 </button>
......