Rafael Lopez

objetos

...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
45 scaleBand() 45 scaleBand()
46 .domain(data.map(d => d.año)) 46 .domain(data.map(d => d.año))
47 .range([0, 100]) // porcentaje 47 .range([0, 100]) // porcentaje
48 - .padding(0.2) 48 + .padding(0.6)
49 ); 49 );
50 50
51 // Para CSS bottom positioning: 0 → 0%, max → 100% 51 // Para CSS bottom positioning: 0 → 0%, max → 100%
...@@ -112,15 +112,11 @@ ...@@ -112,15 +112,11 @@
112 <!-- Eje X --> 112 <!-- Eje X -->
113 <div class="x-axis" style="height: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px"> 113 <div class="x-axis" style="height: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px">
114 {#each data as d, i} 114 {#each data as d, i}
115 - {@const isFirst = i === 0}
116 - {@const isLast = i === data.length - 1}
117 - {@const isMid = i === midIndex && data.length > 5}
118 - {@const showLabel = isFirst || isLast || isMid || hoveredYear === d.año}
119 <span 115 <span
120 - class="x-label" 116 + class="x-label visible"
121 - class:visible={showLabel} 117 + class:hovered={hoveredYear === d.año}
122 > 118 >
123 - {d.año} 119 + {String(d.año).slice(-2)}
124 </span> 120 </span>
125 {/each} 121 {/each}
126 </div> 122 </div>
...@@ -170,7 +166,7 @@ ...@@ -170,7 +166,7 @@
170 bottom: 0; 166 bottom: 0;
171 display: flex; 167 display: flex;
172 align-items: flex-end; 168 align-items: flex-end;
173 - gap: 2px; 169 + gap: 4px;
174 } 170 }
175 171
176 .bar-container { 172 .bar-container {
...@@ -178,11 +174,12 @@ ...@@ -178,11 +174,12 @@
178 height: 100%; 174 height: 100%;
179 display: flex; 175 display: flex;
180 align-items: flex-end; 176 align-items: flex-end;
177 + justify-content: center;
181 cursor: default; 178 cursor: default;
182 } 179 }
183 180
184 .bar { 181 .bar {
185 - width: 100%; 182 + width: 80%;
186 background: #c4897d; 183 background: #c4897d;
187 border-radius: 4px 4px 0 0; 184 border-radius: 4px 4px 0 0;
188 transition: opacity 0.1s ease; 185 transition: opacity 0.1s ease;
...@@ -206,20 +203,27 @@ ...@@ -206,20 +203,27 @@
206 bottom: 0; 203 bottom: 0;
207 display: flex; 204 display: flex;
208 align-items: center; 205 align-items: center;
206 + gap: 4px;
209 } 207 }
210 208
211 .x-label { 209 .x-label {
212 flex: 1; 210 flex: 1;
213 text-align: center; 211 text-align: center;
214 - font-family: 'Qanelas', var(--font-sans); 212 + font-family: 'DM Mono', monospace;
215 - font-size: 0.6875rem; 213 + font-size: 0.5625rem;
216 color: var(--theme-texto); 214 color: var(--theme-texto);
217 opacity: 0; 215 opacity: 0;
218 transition: opacity 0.2s ease; 216 transition: opacity 0.2s ease;
219 } 217 }
220 218
221 .x-label.visible { 219 .x-label.visible {
220 + opacity: 0.5;
221 + }
222 +
223 + .x-label.hovered {
222 opacity: 1; 224 opacity: 1;
225 + color: var(--theme-titulo);
226 + font-weight: 600;
223 } 227 }
224 228
225 .chart-empty { 229 .chart-empty {
......
...@@ -101,12 +101,14 @@ ...@@ -101,12 +101,14 @@
101 padding: 0.125rem 0; 101 padding: 0.125rem 0;
102 background: transparent; 102 background: transparent;
103 border: none; 103 border: none;
104 - border-bottom: 1px dotted #333333; 104 + border-bottom: 1px dotted #666666;
105 color: var(--theme-titulo); 105 color: var(--theme-titulo);
106 font-size: 1rem; 106 font-size: 1rem;
107 font-weight: 500; 107 font-weight: 500;
108 cursor: pointer; 108 cursor: pointer;
109 transition: border-color 0.2s; 109 transition: border-color 0.2s;
110 + max-width: 100%;
111 + min-width: 0;
110 } 112 }
111 113
112 .entity-btn:hover { 114 .entity-btn:hover {
...@@ -115,6 +117,9 @@ ...@@ -115,6 +117,9 @@
115 117
116 .entity-label { 118 .entity-label {
117 text-align: left; 119 text-align: left;
120 + overflow: hidden;
121 + text-overflow: ellipsis;
122 + white-space: nowrap;
118 } 123 }
119 124
120 .chevron { 125 .chevron {
...@@ -132,7 +137,8 @@ ...@@ -132,7 +137,8 @@
132 position: absolute; 137 position: absolute;
133 top: calc(100% + 4px); 138 top: calc(100% + 4px);
134 left: 0; 139 left: 0;
135 - min-width: 280px; 140 + right: 0;
141 + min-width: min(280px, calc(100vw - 2rem));
136 background: var(--theme-surface); 142 background: var(--theme-surface);
137 border: 1px solid var(--theme-borde); 143 border: 1px solid var(--theme-borde);
138 border-radius: 10px; 144 border-radius: 10px;
......
1 <script> 1 <script>
2 import { goto } from '$app/navigation'; 2 import { goto } from '$app/navigation';
3 - import { indexLoaded } from '$lib/stores/searchStore';
4 - import { search } from '$lib/services/search';
5 3
6 let { 4 let {
7 preserveVista = false, 5 preserveVista = false,
...@@ -16,20 +14,46 @@ ...@@ -16,20 +14,46 @@
16 let selectedIdx = $state(-1); 14 let selectedIdx = $state(-1);
17 let debounceTimer; 15 let debounceTimer;
18 16
19 - function handleSearchInput(e) { 17 + function parseMetadatos(meta) {
20 - const val = e.target.value; 18 + if (!meta) return {};
21 - clearTimeout(debounceTimer); 19 + if (typeof meta === 'object') return meta;
22 - debounceTimer = setTimeout(() => { 20 + try { return JSON.parse(meta); } catch { return {}; }
23 - if (val.length >= 2) { 21 + }
22 +
23 + async function doSearch(query) {
24 isSearching = true; 24 isSearching = true;
25 - // Buscar y filtrar solo objetos de gasto 25 + try {
26 - const allResults = search(val, 50); 26 + const params = new URLSearchParams({
27 - localResults = allResults.filter(r => r.tipo === 'objeto_gasto').slice(0, 15); 27 + q: query,
28 + per_page: '20',
29 + is_class: 'true',
30 + class_: 'objeto'
31 + });
32 + const res = await fetch(`/api/search?${params}`);
33 + if (!res.ok) throw new Error();
34 + const data = await res.json();
35 + localResults = (data.hits || []).map(hit => {
36 + const meta = parseMetadatos(hit.document.metadatos);
37 + const codigo = meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '';
38 + const nombre = hit.document.texto;
39 + const highlight = hit.highlights?.[0]?.snippet || nombre;
40 + return { codigo, nombre, highlight };
41 + });
42 + selectedIdx = -1;
43 + } catch {
44 + localResults = [];
45 + }
28 isSearching = false; 46 isSearching = false;
47 + }
48 +
49 + function handleSearchInput(e) {
50 + searchVal = e.target.value;
51 + clearTimeout(debounceTimer);
52 + if (searchVal.length >= 2) {
53 + debounceTimer = setTimeout(() => doSearch(searchVal), 200);
29 } else { 54 } else {
30 localResults = []; 55 localResults = [];
31 } 56 }
32 - }, 200);
33 } 57 }
34 58
35 function handleSearchKeydown(e) { 59 function handleSearchKeydown(e) {
...@@ -86,7 +110,6 @@ ...@@ -86,7 +110,6 @@
86 onfocus={() => searchFocused = true} 110 onfocus={() => searchFocused = true}
87 onblur={handleSearchBlur} 111 onblur={handleSearchBlur}
88 placeholder="Buscar objeto..." 112 placeholder="Buscar objeto..."
89 - disabled={!$indexLoaded}
90 /> 113 />
91 {#if searchVal} 114 {#if searchVal}
92 <button class="search-clear" onclick={handleClear}> 115 <button class="search-clear" onclick={handleClear}>
...@@ -110,7 +133,7 @@ ...@@ -110,7 +133,7 @@
110 onmouseenter={() => selectedIdx = i} 133 onmouseenter={() => selectedIdx = i}
111 > 134 >
112 <span class="item-code">{result.codigo}</span> 135 <span class="item-code">{result.codigo}</span>
113 - <span class="item-name">{result.nombre}</span> 136 + <span class="item-name">{@html result.highlight}</span>
114 </button> 137 </button>
115 {/each} 138 {/each}
116 {/if} 139 {/if}
...@@ -229,4 +252,11 @@ ...@@ -229,4 +252,11 @@
229 font-size: 0.8125rem; 252 font-size: 0.8125rem;
230 color: var(--theme-titulo); 253 color: var(--theme-titulo);
231 } 254 }
255 +
256 + :global(.item-name mark) {
257 + background: rgba(201, 167, 81, 0.3);
258 + color: inherit;
259 + border-radius: 2px;
260 + padding: 0 1px;
261 + }
232 </style> 262 </style>
......
...@@ -72,13 +72,13 @@ ...@@ -72,13 +72,13 @@
72 72
73 // Detectar tipos de clasificadores presentes en los resultados 73 // Detectar tipos de clasificadores presentes en los resultados
74 // Mapeo inverso: API class_ → id interno del clasificador 74 // Mapeo inverso: API class_ → id interno del clasificador
75 - const CLASS_REVERSE_MAP = { ubigeo: 'geografico', acteco: 'sectores' }; 75 + const CLASS_REVERSE_MAP = { ubigeo: 'geografico', acteco: 'sectores', finalidad: 'finfun' };
76 76
77 // Filtrar hits según clasificadores seleccionados (la API puede devolver tipos extra) 77 // Filtrar hits según clasificadores seleccionados (la API puede devolver tipos extra)
78 $: filteredHits = (() => { 78 $: filteredHits = (() => {
79 if (searchMode !== 'clasificadores' || selectedClassifiers.length === 0) return allHits; 79 if (searchMode !== 'clasificadores' || selectedClassifiers.length === 0) return allHits;
80 const allowedClasses = new Set(selectedClassifiers.map(c => { 80 const allowedClasses = new Set(selectedClassifiers.map(c => {
81 - const map = { geografico: 'ubigeo', sectores: 'acteco' }; 81 + const map = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
82 return map[c] || c; 82 return map[c] || c;
83 })); 83 }));
84 return allHits.filter(h => allowedClasses.has(h.document.class_)); 84 return allHits.filter(h => allowedClasses.has(h.document.class_));
...@@ -86,7 +86,12 @@ ...@@ -86,7 +86,12 @@
86 86
87 $: activeClassTypes = (() => { 87 $: activeClassTypes = (() => {
88 const types = new Set(); 88 const types = new Set();
89 - filteredHits.forEach(h => { if (h.document.class_) types.add(h.document.class_); }); 89 + filteredHits.forEach(h => {
90 + if (h.document.class_) {
91 + const normalized = CLASS_REVERSE_MAP[h.document.class_] || h.document.class_;
92 + types.add(normalized);
93 + }
94 + });
90 return types; 95 return types;
91 })(); 96 })();
92 $: isMixedClassSearch = activeClassTypes.size > 1; 97 $: isMixedClassSearch = activeClassTypes.size > 1;
...@@ -154,14 +159,14 @@ ...@@ -154,14 +159,14 @@
154 if (isMixedClassSearch || searchMode === 'todo') { 159 if (isMixedClassSearch || searchMode === 'todo') {
155 // Filtrar duplicados de finfun 160 // Filtrar duplicados de finfun
156 const filtered = filteredHits.filter(hit => { 161 const filtered = filteredHits.filter(hit => {
157 - if (hit.document.class_ !== 'finfun') return true; 162 + if (hit.document.class_ !== 'finfun' && hit.document.class_ !== 'finalidad') return true;
158 const m = parseMetadatos(hit.document.metadatos); 163 const m = parseMetadatos(hit.document.metadatos);
159 return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined); 164 return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined);
160 }); 165 });
161 166
162 const groups = {}; 167 const groups = {};
163 filtered.forEach(hit => { 168 filtered.forEach(hit => {
164 - const type = hit.document.class_ || 'otros'; 169 + const type = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_ || 'otros';
165 const groupName = CLASS_LABELS[type] || type; 170 const groupName = CLASS_LABELS[type] || type;
166 if (!groups[groupName]) { 171 if (!groups[groupName]) {
167 groups[groupName] = { name: groupName, hits: [], totalMonto: 0, classType: type }; 172 groups[groupName] = { name: groupName, hits: [], totalMonto: 0, classType: type };
...@@ -692,7 +697,7 @@ ...@@ -692,7 +697,7 @@
692 const isClassResult = hit.document.is_class === true; 697 const isClassResult = hit.document.is_class === true;
693 698
694 let url; 699 let url;
695 - const isFinfunClass = hit.document.class_ === 'finfun'; 700 + const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad';
696 701
697 const isUbigeoClass = hit.document.class_ === 'ubigeo'; 702 const isUbigeoClass = hit.document.class_ === 'ubigeo';
698 703
...@@ -1207,7 +1212,7 @@ ...@@ -1207,7 +1212,7 @@
1207 {#each group.hits.slice(0, maxVisible) as hit, i (getHitKey(hit, gi * 1000 + i))} 1212 {#each group.hits.slice(0, maxVisible) as hit, i (getHitKey(hit, gi * 1000 + i))}
1208 {@const meta = parseMetadatos(hit.document.metadatos)} 1213 {@const meta = parseMetadatos(hit.document.metadatos)}
1209 {@const globalIdx = flatHitsForNav.indexOf(hit)} 1214 {@const globalIdx = flatHitsForNav.indexOf(hit)}
1210 - {@const hitClass = hit.document.class_} 1215 + {@const hitClass = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_}
1211 {#if hitClass === 'entidad'} 1216 {#if hitClass === 'entidad'}
1212 {@const isDA = meta.da && meta.entidad_desc_entidad} 1217 {@const isDA = meta.da && meta.entidad_desc_entidad}
1213 {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null} 1218 {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
...@@ -1323,10 +1328,10 @@ ...@@ -1323,10 +1328,10 @@
1323 {@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null} 1328 {@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
1324 {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null} 1329 {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
1325 {@const isObjetoClass = hit.document.class_ === 'objeto'} 1330 {@const isObjetoClass = hit.document.class_ === 'objeto'}
1326 - {@const isFinfunClass = hit.document.class_ === 'finfun'} 1331 + {@const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad'}
1327 {@const isUbigeoClass = hit.document.class_ === 'ubigeo'} 1332 {@const isUbigeoClass = hit.document.class_ === 'ubigeo'}
1328 {@const isClassResult = hit.document.is_class === true} 1333 {@const isClassResult = hit.document.is_class === true}
1329 - {@const hitType = hit.document.class_} 1334 + {@const hitType = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_}
1330 <a 1335 <a
1331 href={isEntidadClass 1336 href={isEntidadClass
1332 ? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}` 1337 ? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}`
......
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
...@@ -28,8 +28,8 @@ ...@@ -28,8 +28,8 @@
28 let topEntidadesPorAño = $state({}); 28 let topEntidadesPorAño = $state({});
29 29
30 const POBLACION = 12000000; 30 const POBLACION = 12000000;
31 - const objetoCodigo = '11700'; 31 + let objetoCodigo = $derived(objetoData.objeto);
32 - const objetoNivel = 'subpartida'; 32 + let objetoNivel = $derived(objetoData.nivel);
33 33
34 let gastoPerCapita = $derived( 34 let gastoPerCapita = $derived(
35 datosAnuales.map(d => ({ 35 datosAnuales.map(d => ({
......
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>