Rafael Lopez

objetos

1 node_modules 1 node_modules
2 +API_ENDPOINTS.md
2 3
3 # Output 4 # Output
4 .output 5 .output
......
...@@ -36,9 +36,16 @@ ...@@ -36,9 +36,16 @@
36 let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50)); 36 let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50));
37 37
38 // Máximo del eje Y (con guard para data vacía o valores undefined) 38 // Máximo del eje Y (con guard para data vacía o valores undefined)
39 - let maxPerCapita = $derived( 39 + let maxPerCapita = $derived.by(() => {
40 - data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 1) : 1 40 + if (data.length === 0) return 1;
41 - ); 41 + const vals = data.map(d => typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0);
42 + const max = Math.max(...vals);
43 + const min = Math.min(...vals.filter(v => v > 0));
44 + // Si max es 0, usar 1 como fallback
45 + if (max <= 0) return 1;
46 + // Agregar 10% de margen arriba para que la barra más alta no toque el techo
47 + return max * 1.1;
48 + });
42 49
43 // Escalas D3 50 // Escalas D3
44 let xScale = $derived( 51 let xScale = $derived(
...@@ -92,7 +99,8 @@ ...@@ -92,7 +99,8 @@
92 <!-- Barras --> 99 <!-- Barras -->
93 <div class="bars" onmouseleave={() => hoveredYear = null} role="group"> 100 <div class="bars" onmouseleave={() => hoveredYear = null} role="group">
94 {#each data as d} 101 {#each data as d}
95 - {@const barPct = (yScale(d.perCapita) / chartHeight) * 100} 102 + {@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0}
103 + {@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0}
96 <div 104 <div
97 class="bar-container" 105 class="bar-container"
98 onmouseenter={() => hoveredYear = d.año} 106 onmouseenter={() => hoveredYear = d.año}
...@@ -102,7 +110,7 @@ ...@@ -102,7 +110,7 @@
102 <div 110 <div
103 class="bar" 111 class="bar"
104 class:hovered={hoveredYear === d.año} 112 class:hovered={hoveredYear === d.año}
105 - style="height: {barPct}%" 113 + style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
106 ></div> 114 ></div>
107 </div> 115 </div>
108 {/each} 116 {/each}
......
...@@ -109,7 +109,7 @@ ...@@ -109,7 +109,7 @@
109 onkeydown={handleSearchKeydown} 109 onkeydown={handleSearchKeydown}
110 onfocus={() => searchFocused = true} 110 onfocus={() => searchFocused = true}
111 onblur={handleSearchBlur} 111 onblur={handleSearchBlur}
112 - placeholder="Buscar objeto..." 112 + placeholder="Buscar otro gasto..."
113 /> 113 />
114 {#if searchVal} 114 {#if searchVal}
115 <button class="search-clear" onclick={handleClear}> 115 <button class="search-clear" onclick={handleClear}>
......
...@@ -108,7 +108,19 @@ ...@@ -108,7 +108,19 @@
108 codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`; 108 codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`;
109 } 109 }
110 110
111 - return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion }; 111 + // Contexto extra para programas
112 + let contexto = '';
113 + if (!isClass) {
114 + const ents = meta.entidades || [];
115 + const entNombre = ents.length === 1 ? ents[0].entidad_desc_entidad : ents.length > 1 ? `${ents.length} entidades` : '';
116 + const prog = meta.programa_desc || '';
117 + if (entNombre && prog) contexto = `${entNombre} · Programa: ${prog}`;
118 + else if (entNombre) contexto = entNombre;
119 + else if (prog) contexto = `Programa: ${prog}`;
120 + console.log('[SEARCH] programa contexto:', { isClass, tipo, nombre, entNombre, prog, contexto, metaKeys: Object.keys(meta) });
121 + }
122 +
123 + return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion, contexto };
112 }); 124 });
113 selectedIdx = -1; 125 selectedIdx = -1;
114 } catch { 126 } catch {
...@@ -184,7 +196,6 @@ ...@@ -184,7 +196,6 @@
184 } 196 }
185 197
186 function goToResult(item) { 198 function goToResult(item) {
187 - closeModal();
188 const routes = { 199 const routes = {
189 entidad: '/entidad/', 200 entidad: '/entidad/',
190 objeto_gasto: '/objeto/', 201 objeto_gasto: '/objeto/',
...@@ -196,7 +207,12 @@ ...@@ -196,7 +207,12 @@
196 programa: '/proyecto/' 207 programa: '/proyecto/'
197 }; 208 };
198 const base = routes[item.tipo]; 209 const base = routes[item.tipo];
199 - if (base) goto(`${base}${item.codigo}`); 210 + if (base) {
211 + const url = `${base}${item.codigo}`;
212 + console.log('[SEARCH] goToResult:', item.tipo, item.codigo, '→', url);
213 + open = false;
214 + window.location.href = url;
215 + }
200 } 216 }
201 217
202 function handleBackdropClick(e) { 218 function handleBackdropClick(e) {
...@@ -319,6 +335,9 @@ ...@@ -319,6 +335,9 @@
319 {typeConfig[item.tipo]?.label || item.tipo} 335 {typeConfig[item.tipo]?.label || item.tipo}
320 </span> 336 </span>
321 <span class="result-name">{@html item.highlight}</span> 337 <span class="result-name">{@html item.highlight}</span>
338 + {#if item.contexto}
339 + <span class="result-contexto">{item.contexto}</span>
340 + {/if}
322 </div> 341 </div>
323 <span class="result-code">{item.codigo}</span> 342 <span class="result-code">{item.codigo}</span>
324 </button> 343 </button>
...@@ -646,6 +665,15 @@ ...@@ -646,6 +665,15 @@
646 text-overflow: ellipsis; 665 text-overflow: ellipsis;
647 } 666 }
648 667
668 + .result-contexto {
669 + font-size: 0.75rem;
670 + color: var(--theme-texto);
671 + opacity: 0.6;
672 + white-space: nowrap;
673 + overflow: hidden;
674 + text-overflow: ellipsis;
675 + }
676 +
649 .result-desc { 677 .result-desc {
650 font-size: 0.8125rem; 678 font-size: 0.8125rem;
651 color: var(--theme-texto); 679 color: var(--theme-texto);
......
...@@ -1379,9 +1379,16 @@ ...@@ -1379,9 +1379,16 @@
1379 </div> 1379 </div>
1380 <div class="result-meta"> 1380 <div class="result-meta">
1381 <span class="result-monto">{formatMonto(hit.document.devengado)}</span> 1381 <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
1382 - {#if meta.entidad_desc_entidad} 1382 + {#if meta.entidades?.length === 1}
1383 <span class="result-sep">·</span> 1383 <span class="result-sep">·</span>
1384 - <span class="result-entity">{meta.entidad_desc_entidad}</span> 1384 + <span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span>
1385 + {:else if meta.entidades?.length > 1}
1386 + <span class="result-sep">·</span>
1387 + <span class="result-entity">{meta.entidades.length} entidades</span>
1388 + {/if}
1389 + {#if meta.programa_desc}
1390 + <span class="result-sep">·</span>
1391 + <span class="result-entity">{meta.programa_desc}</span>
1385 {/if} 1392 {/if}
1386 </div> 1393 </div>
1387 </div> 1394 </div>
...@@ -1443,10 +1450,20 @@ ...@@ -1443,10 +1450,20 @@
1443 <span class="result-year">{formatYears(meta.gestion)}</span> 1450 <span class="result-year">{formatYears(meta.gestion)}</span>
1444 <span class="result-sep">·</span> 1451 <span class="result-sep">·</span>
1445 <span class="result-monto">{formatMonto(hit.document.devengado)}</span> 1452 <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
1446 - {#if meta.entidad_desc_entidad} 1453 + {#if meta.entidades?.length === 1}
1454 + <span class="result-sep">·</span>
1455 + <span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span>
1456 + {:else if meta.entidades?.length > 1}
1457 + <span class="result-sep">·</span>
1458 + <span class="result-entity">{meta.entidades.length} entidades</span>
1459 + {:else if meta.entidad_desc_entidad}
1447 <span class="result-sep">·</span> 1460 <span class="result-sep">·</span>
1448 <span class="result-entity">{meta.entidad_desc_entidad}</span> 1461 <span class="result-entity">{meta.entidad_desc_entidad}</span>
1449 {/if} 1462 {/if}
1463 + {#if meta.programa_desc}
1464 + <span class="result-sep">·</span>
1465 + <span class="result-entity">Programa: {meta.programa_desc}</span>
1466 + {/if}
1450 {/if} 1467 {/if}
1451 {#if isEntidadClass || isClassResult} 1468 {#if isEntidadClass || isClassResult}
1452 <span class="result-monto">{formatMonto(hit.document.devengado)}</span> 1469 <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
......
1 +const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + if (!response.ok) {
7 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
8 + status: response.status, headers: { 'Content-Type': 'application/json' }
9 + });
10 + }
11 + const data = await response.json();
12 + return new Response(JSON.stringify(data), {
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + } catch {
16 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
17 + status: 500, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +}
1 +const API_BASE = 'http://136.112.29.74/api/objeto/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + if (!response.ok) {
7 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
8 + status: response.status, headers: { 'Content-Type': 'application/json' }
9 + });
10 + }
11 + const data = await response.json();
12 + return new Response(JSON.stringify(data), {
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + } catch {
16 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
17 + status: 500, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +}
1 +const API_BASE = 'http://136.112.29.74/api/objeto';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'clasificador';
6 + const entidad = url.searchParams.get('entidad') || '';
7 + const gestion = url.searchParams.get('gestion') || '';
8 +
9 + if (!codigo) {
10 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
11 + status: 400, headers: { 'Content-Type': 'application/json' }
12 + });
13 + }
14 +
15 + let apiUrl;
16 + if (tipo === 'clasificador') {
17 + apiUrl = `${API_BASE}/${codigo}`;
18 + } else if (tipo === 'entidades-lista') {
19 + apiUrl = `${API_BASE}/${codigo}/entidades/map`;
20 + } else if (tipo === 'entidad' && entidad) {
21 + apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
22 + } else if (tipo === 'entidades-año' && gestion) {
23 + apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
24 + } else {
25 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
26 + status: 400, headers: { 'Content-Type': 'application/json' }
27 + });
28 + }
29 +
30 + try {
31 + const response = await fetch(apiUrl);
32 + if (!response.ok) {
33 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
34 + status: response.status, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 + const data = await response.json();
38 + return new Response(JSON.stringify(data), {
39 + headers: { 'Content-Type': 'application/json' }
40 + });
41 + } catch (err) {
42 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
43 + status: 500, headers: { 'Content-Type': 'application/json' }
44 + });
45 + }
46 +}
1 +const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const res = await fetch(API_BASE);
6 + if (!res.ok) throw new Error();
7 + const data = await res.json();
8 + // Mapear al formato que espera el treemap
9 + const mapped = data.map(d => ({
10 + entidad: d.entidad,
11 + desc_entidad: d.desc_entidad,
12 + sigla_entidad: d.sigla_entidad || ''
13 + }));
14 + return new Response(JSON.stringify(mapped), {
15 + headers: { 'Content-Type': 'application/json' }
16 + });
17 + } catch {
18 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
19 + status: 500, headers: { 'Content-Type': 'application/json' }
20 + });
21 + }
22 +}
1 +const API_BASE = 'http://136.112.29.74/api/objeto';
2 +
3 +// Cache en memoria del servidor
4 +let cachedClasificador = null;
5 +let cachedEstados = null; // Map: objeto → estados[]
6 +let cachedTimestamp = 0;
7 +const CACHE_TTL = 5 * 60 * 1000; // 5 minutos
8 +
9 +async function loadAndCacheAll() {
10 + const now = Date.now();
11 + if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
12 +
13 + // 1. Cargar clasificador
14 + const clasRes = await fetch(`${API_BASE}/clasificador`);
15 + if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
16 + cachedClasificador = await clasRes.json();
17 +
18 + // 2. Cargar todos los objetos en paralelo (lotes de 80)
19 + const estados = new Map();
20 + const batchSize = 80;
21 +
22 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
23 + const batch = cachedClasificador.slice(i, i + batchSize);
24 + const results = await Promise.all(batch.map(async (item) => {
25 + try {
26 + const res = await fetch(`${API_BASE}/${item.objeto}`);
27 + if (!res.ok) return null;
28 + const data = await res.json();
29 + return { objeto: item.objeto, estados: data.estados || [] };
30 + } catch { return null; }
31 + }));
32 + results.filter(Boolean).forEach(r => estados.set(r.objeto, r.estados));
33 + }
34 +
35 + cachedEstados = estados;
36 + cachedTimestamp = now;
37 +}
38 +
39 +export async function GET({ url }) {
40 + const gestion = parseInt(url.searchParams.get('gestion') || '2025');
41 + const entidad = url.searchParams.get('entidad') || '0';
42 +
43 + try {
44 + if (entidad === '0') {
45 + // Todo el estado — usar cache
46 + await loadAndCacheAll();
47 +
48 + const results = [];
49 + for (const item of cachedClasificador) {
50 + const estados = cachedEstados.get(item.objeto);
51 + if (!estados) continue;
52 + const yearData = estados.find(d => d.gestion === gestion);
53 + if (!yearData || !yearData.total) continue;
54 + results.push({
55 + gestion,
56 + nivel: item.nivel,
57 + objeto: item.objeto,
58 + desc_objeto: item.desc_objeto,
59 + parent: getParent(item.objeto, item.nivel),
60 + devengado: yearData.total
61 + });
62 + }
63 +
64 + return new Response(JSON.stringify(results), {
65 + headers: { 'Content-Type': 'application/json' }
66 + });
67 + } else {
68 + // Entidad específica — cargar por entidad (no cacheable eficientemente)
69 + if (!cachedClasificador) {
70 + const clasRes = await fetch(`${API_BASE}/clasificador`);
71 + cachedClasificador = await clasRes.json();
72 + }
73 +
74 + const results = [];
75 + const batchSize = 80;
76 +
77 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
78 + const batch = cachedClasificador.slice(i, i + batchSize);
79 + const batchResults = await Promise.all(batch.map(async (item) => {
80 + try {
81 + const res = await fetch(`${API_BASE}/${item.objeto}/entidades/${entidad}`);
82 + if (!res.ok) return null;
83 + const data = await res.json();
84 + const yearData = data.find(d => d.gestion === gestion);
85 + if (!yearData || !yearData.monto) return null;
86 + return {
87 + gestion,
88 + nivel: item.nivel,
89 + objeto: item.objeto,
90 + desc_objeto: item.desc_objeto,
91 + parent: getParent(item.objeto, item.nivel),
92 + devengado: yearData.monto
93 + };
94 + } catch { return null; }
95 + }));
96 + results.push(...batchResults.filter(Boolean));
97 + }
98 +
99 + return new Response(JSON.stringify(results), {
100 + headers: { 'Content-Type': 'application/json' }
101 + });
102 + }
103 + } catch (err) {
104 + return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
105 + status: 500, headers: { 'Content-Type': 'application/json' }
106 + });
107 + }
108 +}
109 +
110 +function getParent(objeto, nivel) {
111 + if (nivel === 'grupo') return null;
112 + if (nivel === 'subgrupo') return objeto.charAt(0) + '0000';
113 + if (nivel === 'partida') return objeto.substring(0, 2) + '000';
114 + if (nivel === 'subpartida') return objeto.substring(0, 3) + '00';
115 + return null;
116 +}
1 <script> 1 <script>
2 - import { supabase } from '$lib/supabase';
3 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
4 import { page } from '$app/stores'; 3 import { page } from '$app/stores';
5 import { goto } from '$app/navigation'; 4 import { goto } from '$app/navigation';
...@@ -97,7 +96,8 @@ ...@@ -97,7 +96,8 @@
97 96
98 // Selector de nivel para vista aplanada 97 // Selector de nivel para vista aplanada
99 let treemapViewLevel = $state('partida'); // 'jerarquico' | 'grupo' | 'subgrupo' | 'partida' | 'subpartida' 98 let treemapViewLevel = $state('partida'); // 'jerarquico' | 'grupo' | 'subgrupo' | 'partida' | 'subpartida'
100 - let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias) 99 + let showConsolidado = $state(true);
100 + let clasLinkCopied = $state(false);
101 const NIVEL_OPTIONS = [ 101 const NIVEL_OPTIONS = [
102 { value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' }, 102 { value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' },
103 { value: 'grupo', label: 'Grupos', desc: '8-9 categorías' }, 103 { value: 'grupo', label: 'Grupos', desc: '8-9 categorías' },
...@@ -193,42 +193,22 @@ ...@@ -193,42 +193,22 @@
193 193
194 // Cargar datos del treemap desde Supabase 194 // Cargar datos del treemap desde Supabase
195 async function loadTreemapData() { 195 async function loadTreemapData() {
196 - // Limpiar estado antes de cargar nuevos datos
197 treemapNodes = []; 196 treemapNodes = [];
198 197
199 try { 198 try {
200 - // Query a Supabase con filtros en el servidor para mejor rendimiento
201 - // selectedEntity = null significa "Todo el Estado" (entidad = 0)
202 const entidadFiltro = selectedEntity?.entidad ?? 0; 199 const entidadFiltro = selectedEntity?.entidad ?? 0;
200 + const res = await fetch(`/api/objeto-treemap?gestion=${selectedYear}&entidad=${entidadFiltro}`);
201 + const data = await res.json();
203 202
204 - let query = supabase 203 + if (data.error) {
205 - .schema('ppto') 204 + console.error('Error fetching treemap data:', data.error);
206 - .from('treemap_objeto')
207 - .select('gestion, nivel, objeto, desc_objeto, parent, devengado')
208 - .eq('gestion', selectedYear)
209 - .eq('entidad', entidadFiltro)
210 - .gt('devengado', 0);
211 -
212 - const { data, error: dbError } = await query;
213 -
214 - if (dbError) {
215 - console.error('Error fetching treemap data:', dbError.message, dbError.code, dbError.details);
216 return; 205 return;
217 } 206 }
218 207
219 - // Procesar datos: convertir tipos y filtrar subpartidas duplicadas
220 let filtered = data 208 let filtered = data
221 - .map(d => ({ 209 + .filter(d => d.devengado > 0)
222 - gestion: d.gestion,
223 - nivel: d.nivel,
224 - objeto: d.objeto,
225 - desc_objeto: d.desc_objeto,
226 - parent: d.parent,
227 - devengado: d.devengado || 0
228 - }))
229 .filter(d => !(d.nivel === 'subpartida' && d.desc_objeto === null)); 210 .filter(d => !(d.nivel === 'subpartida' && d.desc_objeto === null));
230 211
231 - // Si es vista consolidada, excluir grupo 7 (Transferencias)
232 if (showConsolidado) { 212 if (showConsolidado) {
233 filtered = filtered.filter(d => !String(d.objeto).startsWith('7')); 213 filtered = filtered.filter(d => !String(d.objeto).startsWith('7'));
234 } 214 }
...@@ -246,28 +226,18 @@ ...@@ -246,28 +226,18 @@
246 if (!year) return; 226 if (!year) return;
247 227
248 try { 228 try {
249 - const { data, error: dbError } = await supabase 229 + const res = await fetch('/api/objeto-treemap-entidades');
250 - .schema('ppto') 230 + const data = await res.json();
251 - .from('entidades_treemap')
252 - .select('entidad, desc_entidad, sigla_entidad')
253 - .eq('gestion', year)
254 - .order('desc_entidad');
255 -
256 - if (dbError) {
257 - console.error('Error loading entities:', dbError.message);
258 - return;
259 - }
260 231
261 - entities = data || []; 232 + if (Array.isArray(data)) {
262 - console.log('Entities loaded for year', year, ':', entities.length); 233 + entities = data.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
234 + console.log('Entities loaded:', entities.length);
235 + }
263 236
264 - // Si hay entidad seleccionada, verificar que existe en el nuevo año
265 if (selectedEntity) { 237 if (selectedEntity) {
266 const existsInYear = entities.some(e => e.entidad === selectedEntity.entidad); 238 const existsInYear = entities.some(e => e.entidad === selectedEntity.entidad);
267 if (!existsInYear) { 239 if (!existsInYear) {
268 - // La entidad no existe en este año, volver a Todo el Estado
269 selectedEntity = null; 240 selectedEntity = null;
270 - console.log('Entity not available for year', year, '- reset to Todo el Estado');
271 } 241 }
272 } 242 }
273 } catch (err) { 243 } catch (err) {
...@@ -549,10 +519,12 @@ ...@@ -549,10 +519,12 @@
549 519
550 // Navegar a vista de distribución por entidades 520 // Navegar a vista de distribución por entidades
551 function navigateToEntityDistribution(node) { 521 function navigateToEntityDistribution(node) {
552 - // Obtener el código del objeto (puede ser grupo, subgrupo, partida o subpartida) 522 + let codigo = node.id || node.data?.objeto;
553 - const codigo = node.id || node.data?.objeto; 523 + // Si es un grupo (id como "grupo_1"), convertir a código real
524 + if (typeof codigo === 'string' && codigo.startsWith('grupo_')) {
525 + codigo = codigo.replace('grupo_', '') + '0000';
526 + }
554 if (codigo) { 527 if (codigo) {
555 - // Navegar a la ruta de distribución por entidades
556 goto(`/objeto/${codigo}/entidades?gestion=${selectedYear}`); 528 goto(`/objeto/${codigo}/entidades?gestion=${selectedYear}`);
557 } 529 }
558 } 530 }
...@@ -797,56 +769,34 @@ ...@@ -797,56 +769,34 @@
797 return; 769 return;
798 } 770 }
799 771
800 - // Si no hay cache, cargar datos 772 + // Cargar clasificador de objetos desde API
801 - // Cargar clasificador de objetos 773 + try {
802 - const { data, error } = await supabase 774 + const res = await fetch('/api/objeto-clasificador');
803 - .schema('ppto') 775 + const data = await res.json();
804 - .from('clas_objetos')
805 - .select('*')
806 - .order('objeto')
807 - .range(0, 9999);
808 776
809 - if (!error && data) { 777 + if (Array.isArray(data)) {
810 allItems = data; 778 allItems = data;
811 779
812 - // Extraer grupos únicos
813 grupos = data 780 grupos = data
814 .filter(item => item.nivel === 'grupo') 781 .filter(item => item.nivel === 'grupo')
815 - .reduce((acc, item) => { 782 + .sort((a, b) => String(a.objeto).localeCompare(String(b.objeto)));
816 - if (!acc.find(g => g.objeto === item.objeto)) {
817 - acc.push(item);
818 - }
819 - return acc;
820 - }, [])
821 - .sort((a, b) => a.objeto.localeCompare(b.objeto));
822 783
823 if (grupos.length > 0) { 784 if (grupos.length > 0) {
824 selectedGrupo = grupos[0]; 785 selectedGrupo = grupos[0];
825 } 786 }
826 - }
827 787
828 - // Cargar años disponibles desde Supabase 788 + // Años desde gestiones del primer grupo
829 - try { 789 + if (data[0]?.gestiones) {
830 - const { data: yearsData, error: yearsError } = await supabase 790 + availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a);
831 - .schema('ppto') 791 + selectedYear = availableYears[0];
832 - .from('treemap_objeto')
833 - .select('gestion')
834 - .eq('entidad', 0)
835 - .eq('nivel', 'grupo')
836 - .gt('devengado', 0);
837 -
838 - if (!yearsError && yearsData && yearsData.length > 0) {
839 - const uniqueYears = [...new Set(yearsData.map(d => d.gestion))].sort((a, b) => b - a);
840 - availableYears = uniqueYears;
841 - selectedYear = availableYears[0]; // Año más reciente por defecto
842 } else { 792 } else {
843 - // Fallback: años de 2005 a 2025 793 + availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
844 - availableYears = Array.from({ length: 21 }, (_, i) => 2025 - i);
845 selectedYear = 2025; 794 selectedYear = 2025;
846 } 795 }
796 + }
847 } catch (err) { 797 } catch (err) {
848 - // Fallback si falla la carga 798 + console.error('Error loading clasificador:', err);
849 - availableYears = Array.from({ length: 21 }, (_, i) => 2025 - i); 799 + availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
850 selectedYear = 2025; 800 selectedYear = 2025;
851 } 801 }
852 802
...@@ -1061,16 +1011,11 @@ ...@@ -1061,16 +1011,11 @@
1061 const entidadFiltro = config.entity?.entidad ?? 0; 1011 const entidadFiltro = config.entity?.entidad ?? 0;
1062 1012
1063 try { 1013 try {
1064 - const { data, error: dbError } = await supabase 1014 + const res = await fetch(`/api/objeto-treemap?gestion=${config.year}&entidad=${entidadFiltro}`);
1065 - .schema('ppto') 1015 + const data = await res.json();
1066 - .from('treemap_objeto') 1016 +
1067 - .select('gestion, nivel, objeto, desc_objeto, parent, devengado') 1017 + if (data.error) {
1068 - .eq('gestion', config.year) 1018 + console.error(`Error loading compare ${side} data:`, data.error);
1069 - .eq('entidad', entidadFiltro)
1070 - .gt('devengado', 0);
1071 -
1072 - if (dbError) {
1073 - console.error(`Error loading compare ${side} data:`, dbError);
1074 return; 1019 return;
1075 } 1020 }
1076 1021
...@@ -1542,9 +1487,6 @@ ...@@ -1542,9 +1487,6 @@
1542 </nav> 1487 </nav>
1543 1488
1544 <div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}"> 1489 <div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
1545 - <p class="text-sm uppercase tracking-widest mb-2 font-semibold" style="font-family: var(--font-sans); color: var(--theme-accent);">
1546 - Clasificador de Objeto del Gasto
1547 - </p>
1548 <div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}"> 1490 <div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}">
1549 <h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);"> 1491 <h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
1550 ¿En qué se gasta? 1492 ¿En qué se gasta?
...@@ -1563,6 +1505,29 @@ ...@@ -1563,6 +1505,29 @@
1563 <span class="hidden sm:inline">Cómo leer</span> 1505 <span class="hidden sm:inline">Cómo leer</span>
1564 </button> 1506 </button>
1565 {/if} 1507 {/if}
1508 + <div style="margin-left: auto; display: flex; align-items: center; gap: 0.75rem;">
1509 + <button class="share-btn-clas" onclick={() => {
1510 + navigator.clipboard.writeText(window.location.href);
1511 + clasLinkCopied = true;
1512 + setTimeout(() => clasLinkCopied = false, 2000);
1513 + }}>
1514 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1515 + {#if clasLinkCopied}
1516 + <path d="M20 6L9 17l-5-5"/>
1517 + {:else}
1518 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
1519 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
1520 + {/if}
1521 + </svg>
1522 + {clasLinkCopied ? 'Copiado' : 'Compartir'}
1523 + </button>
1524 + <a href="/" class="share-btn-clas" style="text-decoration: none;">
1525 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1526 + <path d="M19 12H5M12 19l-7-7 7-7"/>
1527 + </svg>
1528 + Volver
1529 + </a>
1530 + </div>
1566 </div> 1531 </div>
1567 {#if viewMode === 'mapa'} 1532 {#if viewMode === 'mapa'}
1568 <div class="title-selectors"> 1533 <div class="title-selectors">
...@@ -2150,42 +2115,6 @@ ...@@ -2150,42 +2115,6 @@
2150 </button> 2115 </button>
2151 2116
2152 <div class="sidebar-content"> 2117 <div class="sidebar-content">
2153 - <!-- Selector de Modo (Lista, Mapa, Comparar) -->
2154 - <div class="sidebar-section">
2155 - <label class="sidebar-label">Explorar</label>
2156 - <div class="sidebar-modes">
2157 - <button
2158 - class="mode-btn"
2159 - class:active={viewMode === 'lista'}
2160 - onclick={() => { setMode('lista'); mapaSidebarOpen = false; }}
2161 - >
2162 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2163 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
2164 - </svg>
2165 - Lista
2166 - </button>
2167 - <button
2168 - class="mode-btn"
2169 - class:active={viewMode === 'mapa'}
2170 - onclick={() => { setMode('mapa'); mapaSidebarOpen = false; }}
2171 - >
2172 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2173 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
2174 - </svg>
2175 - Mapa
2176 - </button>
2177 - <button
2178 - class="mode-btn"
2179 - class:active={viewMode === 'comparar'}
2180 - onclick={() => { setMode('comparar'); initCompareMode(); mapaSidebarOpen = false; }}
2181 - >
2182 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2183 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
2184 - </svg>
2185 - Comparar
2186 - </button>
2187 - </div>
2188 - </div>
2189 2118
2190 <!-- Navegación drill-down (solo en modo jerárquico con depth > 1) --> 2119 <!-- Navegación drill-down (solo en modo jerárquico con depth > 1) -->
2191 {#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 1} 2120 {#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 1}
...@@ -2227,24 +2156,6 @@ ...@@ -2227,24 +2156,6 @@
2227 </div> 2156 </div>
2228 </div> 2157 </div>
2229 2158
2230 - <!-- Toggle Consolidado/Agregado -->
2231 - <div class="sidebar-section">
2232 - <label class="sidebar-label">Tipo</label>
2233 - <button
2234 - class="sidebar-toggle"
2235 - onclick={() => showConsolidado = !showConsolidado}
2236 - >
2237 - <span class="toggle-switch" class:active={showConsolidado}>
2238 - <span class="toggle-knob"></span>
2239 - </span>
2240 - <span class="toggle-label">
2241 - {showConsolidado ? 'Consolidado' : 'Agregado'}
2242 - </span>
2243 - </button>
2244 - <p class="toggle-hint">
2245 - {showConsolidado ? 'Sin transferencias entre entidades' : 'Incluye transferencias (grupo 7)'}
2246 - </p>
2247 - </div>
2248 </div> 2159 </div>
2249 </aside> 2160 </aside>
2250 2161
...@@ -2293,10 +2204,10 @@ ...@@ -2293,10 +2204,10 @@
2293 <g 2204 <g
2294 class="treemap-node" 2205 class="treemap-node"
2295 transform="translate({node.x0}, {node.y0})" 2206 transform="translate({node.x0}, {node.y0})"
2296 - onclick={() => hasChildren && drillDown(node)} 2207 + onclick={() => hasChildren ? drillDown(node) : goto(`/objeto/${node.id}`)}
2297 onmouseenter={() => hoveredNode = node} 2208 onmouseenter={() => hoveredNode = node}
2298 onmouseleave={() => hoveredNode = null} 2209 onmouseleave={() => hoveredNode = null}
2299 - style="cursor: {hasChildren ? 'pointer' : 'default'};" 2210 + style="cursor: pointer;"
2300 > 2211 >
2301 <rect 2212 <rect
2302 width={width} 2213 width={width}
...@@ -2603,31 +2514,6 @@ ...@@ -2603,31 +2514,6 @@
2603 </button> 2514 </button>
2604 2515
2605 <div class="sidebar-content"> 2516 <div class="sidebar-content">
2606 - <!-- Selector de Modo -->
2607 - <div class="sidebar-section">
2608 - <label class="sidebar-label">Explorar</label>
2609 - <div class="sidebar-modes">
2610 - <button class="mode-btn" class:active={viewMode === 'lista'} onclick={() => { setMode('lista'); mapaSidebarOpen = false; }}>
2611 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2612 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
2613 - </svg>
2614 - Lista
2615 - </button>
2616 - <button class="mode-btn" class:active={viewMode === 'mapa'} onclick={() => { setMode('mapa'); mapaSidebarOpen = false; }}>
2617 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2618 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
2619 - </svg>
2620 - Mapa
2621 - </button>
2622 - <button class="mode-btn active" onclick={() => mapaSidebarOpen = false}>
2623 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2624 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
2625 - </svg>
2626 - Comparar
2627 - </button>
2628 - </div>
2629 - </div>
2630 -
2631 <!-- Selector de Nivel --> 2517 <!-- Selector de Nivel -->
2632 <div class="sidebar-section"> 2518 <div class="sidebar-section">
2633 <label class="sidebar-label">Nivel</label> 2519 <label class="sidebar-label">Nivel</label>
...@@ -2652,18 +2538,6 @@ ...@@ -2652,18 +2538,6 @@
2652 </div> 2538 </div>
2653 </div> 2539 </div>
2654 2540
2655 - <!-- Toggle Consolidado/Agregado -->
2656 - <div class="sidebar-section">
2657 - <label class="sidebar-label">Tipo</label>
2658 - <button class="sidebar-toggle" onclick={() => showConsolidado = !showConsolidado}>
2659 - <span class="toggle-switch" class:active={showConsolidado}>
2660 - <span class="toggle-knob"></span>
2661 - </span>
2662 - <span class="toggle-label">{showConsolidado ? 'Consolidado' : 'Agregado'}</span>
2663 - </button>
2664 - <p class="toggle-hint">{showConsolidado ? 'Sin transferencias' : 'Con transferencias'}</p>
2665 - </div>
2666 -
2667 <!-- Botón intercambiar --> 2541 <!-- Botón intercambiar -->
2668 <div class="sidebar-section"> 2542 <div class="sidebar-section">
2669 <button 2543 <button
...@@ -4201,6 +4075,22 @@ ...@@ -4201,6 +4075,22 @@
4201 box-shadow: 0 4px 24px rgba(28,28,26,0.12), inset 0 0 0 1px rgba(28,28,26,0.06) !important; 4075 box-shadow: 0 4px 24px rgba(28,28,26,0.12), inset 0 0 0 1px rgba(28,28,26,0.06) !important;
4202 } 4076 }
4203 4077
4078 + .share-btn-clas {
4079 + display: flex;
4080 + align-items: center;
4081 + gap: 0.25rem;
4082 + font-size: 0.6875rem;
4083 + color: var(--theme-texto);
4084 + opacity: 0.5;
4085 + background: none;
4086 + border: none;
4087 + padding: 0;
4088 + cursor: pointer;
4089 + transition: opacity 0.15s;
4090 + margin-left: auto;
4091 + }
4092 + .share-btn-clas:hover { opacity: 1; }
4093 +
4204 /* Toggle button para Consolidado/Agregado */ 4094 /* Toggle button para Consolidado/Agregado */
4205 .toggle-btn { 4095 .toggle-btn {
4206 border: 1px solid var(--theme-borde); 4096 border: 1px solid var(--theme-borde);
......
1 -import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 1 import { error } from '@sveltejs/kit';
3 2
4 -// Funciones para derivar jerarquía del código objeto 3 +const API_BASE = 'http://136.112.29.74/api/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 4
24 export async function load({ params }) { 5 export async function load({ params }) {
25 - console.time('[SERVER] Total load objeto');
26 const { codigo } = params; 6 const { codigo } = params;
27 7
28 - console.time('[SERVER] Query clas_objetos'); 8 + // Cargar clasificador desde la API
29 - const { data, error: dbError } = await supabase 9 + const res = await fetch(`${API_BASE}/${codigo}`);
30 - .schema('ppto') 10 + if (!res.ok) throw error(404, 'Objeto no encontrado');
31 - .from('clas_objetos') 11 + const objeto = await res.json();
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 12
48 - console.time('[SERVER] Query padres'); 13 + // Derivar padres e hijos del código
14 + const nivel = objeto.nivel;
15 + let padresCodes = [];
49 16
50 - // Buscar padres según nivel
51 if (nivel === 'subpartida') { 17 if (nivel === 'subpartida') {
52 - // Padres: grupo, subgrupo, partida 18 + padresCodes = [
53 - const grupoCode = getGrupoCode(codigo); 19 + codigo.charAt(0) + '0000',
54 - const subgrupoCode = getSubgrupoCode(codigo); 20 + codigo.substring(0, 2) + '000',
55 - const partidaCode = getPartidaCode(codigo); 21 + codigo.substring(0, 3) + '00'
56 - 22 + ];
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') { 23 } else if (nivel === 'partida') {
66 - // Padres: grupo, subgrupo 24 + padresCodes = [
67 - const grupoCode = getGrupoCode(codigo); 25 + codigo.charAt(0) + '0000',
68 - const subgrupoCode = getSubgrupoCode(codigo); 26 + codigo.substring(0, 2) + '000'
69 - 27 + ];
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') { 28 } else if (nivel === 'subgrupo') {
79 - // Padre: grupo 29 + padresCodes = [codigo.charAt(0) + '0000'];
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 } 30 }
90 - // grupo no tiene padres
91 -
92 - console.timeEnd('[SERVER] Query padres');
93 31
94 - console.time('[SERVER] Query hijos'); 32 + // Cargar padres e hijos en paralelo desde la API
33 + const [padresResults, hijosResult] = await Promise.all([
34 + Promise.all(padresCodes.map(async (c) => {
35 + try {
36 + const r = await fetch(`${API_BASE}/${c}`);
37 + if (r.ok) return await r.json();
38 + } catch {}
39 + return null;
40 + })),
41 + (async () => {
42 + // Hijos: buscar en el clasificador
43 + try {
44 + const r = await fetch(`${API_BASE}/clasificador`);
45 + if (!r.ok) return [];
46 + const all = await r.json();
95 47
96 - // Buscar hijos según nivel
97 if (nivel === 'grupo') { 48 if (nivel === 'grupo') {
98 - // Hijos: subgrupos que empiecen con el mismo dígito 49 + return all.filter(o => o.nivel === 'subgrupo' && o.objeto.charAt(0) === codigo.charAt(0) && o.objeto !== codigo);
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') { 50 } else if (nivel === 'subgrupo') {
110 - // Hijos: partidas que empiecen con los mismos 2 dígitos 51 + return all.filter(o => o.nivel === 'partida' && o.objeto.substring(0, 2) === codigo.substring(0, 2));
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') { 52 } else if (nivel === 'partida') {
122 - // Hijos: subpartidas que empiecen con los mismos 3 dígitos 53 + return all.filter(o => o.nivel === 'subpartida' && o.objeto.substring(0, 3) === codigo.substring(0, 3));
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 } 54 }
134 - // subpartida no tiene hijos 55 + return [];
56 + } catch { return []; }
57 + })()
58 + ]);
135 59
136 - console.timeEnd('[SERVER] Query hijos'); 60 + const padres = padresResults.filter(Boolean).sort((a, b) => a.objeto.localeCompare(b.objeto));
61 + const hijos = hijosResult.sort((a, b) => a.objeto.localeCompare(b.objeto));
137 62
138 - console.timeEnd('[SERVER] Total load objeto');
139 return { 63 return {
140 objeto, 64 objeto,
141 padres, 65 padres,
142 - hijos 66 + hijos,
67 + nEntidades: 0
143 }; 68 };
144 } 69 }
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
4 import { cubicOut } from 'svelte/easing'; 4 import { cubicOut } from 'svelte/easing';
5 import { goto } from '$app/navigation'; 5 import { goto } from '$app/navigation';
6 import { page } from '$app/stores'; 6 import { page } from '$app/stores';
7 - import { supabase } from '$lib/supabase';
8 import Spinner from '$lib/components/ui/Spinner.svelte'; 7 import Spinner from '$lib/components/ui/Spinner.svelte';
9 import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte'; 8 import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
10 import VistaToggle from '$lib/components/objeto/VistaToggle.svelte'; 9 import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
...@@ -18,38 +17,62 @@ ...@@ -18,38 +17,62 @@
18 let hijos = $derived(data.hijos); 17 let hijos = $derived(data.hijos);
19 18
20 let mounted = $state(false); 19 let mounted = $state(false);
21 - let loading = $state(true); 20 + let loading = $state(false);
22 let hoveredYear = $state(null); 21 let hoveredYear = $state(null);
23 let showDefinicionesModal = $state(false); 22 let showDefinicionesModal = $state(false);
24 let showMobileSidebar = $state(false); 23 let showMobileSidebar = $state(false);
25 let metrica = $state('percapita'); // 'percapita' | 'monto' 24 let metrica = $state('percapita'); // 'percapita' | 'monto'
26 let vista = $state('agregado'); // 'agregado' | 'comparar' 25 let vista = $state('agregado'); // 'agregado' | 'comparar'
26 + let linkCopied = $state(false);
27 27
28 - // Parsear descripciones 28 + // Parsear descripciones - la API devuelve array directo con { gestion, descripcion }
29 - function parseDescripciones(str) { 29 + function getMaxYear(str) {
30 - if (!str) return []; 30 + if (!str) return 0;
31 - try { 31 + const years = str.match(/\d{4}/g);
32 - const parsed = JSON.parse(str); 32 + return years ? Math.max(...years.map(Number)) : 0;
33 - return parsed.sort((a, b) => {
34 - const yA = a.rangos?.match(/\d{4}/g) || [];
35 - const yB = b.rangos?.match(/\d{4}/g) || [];
36 - return Math.max(...yB.map(Number), 0) - Math.max(...yA.map(Number), 0);
37 - });
38 - } catch { return []; }
39 } 33 }
40 34
41 - let descripciones = $derived(parseDescripciones(objetoData.descripciones)); 35 + let descripciones = $derived.by(() => {
42 - let variaciones = $derived(descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion }))); 36 + const descs = objetoData.descripciones;
37 + if (!descs) return [];
38 + let arr;
39 + if (typeof descs === 'string') {
40 + try { arr = JSON.parse(descs); } catch { return []; }
41 + } else {
42 + arr = Array.isArray(descs) ? descs : [];
43 + }
44 + // Ordenar por año más reciente primero
45 + return [...arr].sort((a, b) => getMaxYear(b.gestion || b.rangos) - getMaxYear(a.gestion || a.rangos));
46 + });
47 + let variaciones = $derived(descripciones.map(d => ({ rango: d.gestion || d.rangos || '', texto: d.descripcion })));
43 48
44 // Estado selector entidad 49 // Estado selector entidad
45 let entidades = $state([]); 50 let entidades = $state([]);
46 let selectedEntity = $state(null); 51 let selectedEntity = $state(null);
47 - let nEntidades = $derived(data.nEntidades); 52 + let nEntidades = $derived(entidades.length || data.nEntidades || 0);
53 +
54 + // Datos — inicializar desde estados del server
55 + function initEstados(obj) {
56 + const estadosRaw = obj?.estados || [];
57 + const seen = new Set();
58 + const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
59 + const hist = estados.find(d => d.gestion === 0) || null;
60 + const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
61 + const topPorAño = {};
62 + anuales.forEach(d => {
63 + topPorAño[d.gestion] = [
64 + { nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
65 + { nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
66 + { nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
67 + ].filter(e => e.nombre);
68 + });
69 + return { hist, anuales, topPorAño };
70 + }
48 71
49 - // Datos 72 + let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(objetoData);
50 - let datosAnuales = $state([]); 73 + let datosAnuales = $state(initAnuales);
51 - let datosHistorico = $state(null); 74 + let datosHistorico = $state(initHist);
52 - let topEntidadesPorAño = $state({}); 75 + let topEntidadesPorAño = $state(initTop);
53 76
54 const POBLACION = 12000000; 77 const POBLACION = 12000000;
55 let objetoCodigo = $derived(objetoData.objeto); 78 let objetoCodigo = $derived(objetoData.objeto);
...@@ -57,8 +80,8 @@ ...@@ -57,8 +80,8 @@
57 80
58 let gastoPerCapita = $derived( 81 let gastoPerCapita = $derived(
59 datosAnuales.map(d => { 82 datosAnuales.map(d => {
60 - const monto = selectedEntity ? (d.monto ?? d.devengado) : d.total; 83 + const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
61 - const perCapita = d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0); 84 + const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
62 return { 85 return {
63 año: d.gestion, 86 año: d.gestion,
64 monto, 87 monto,
...@@ -70,35 +93,66 @@ ...@@ -70,35 +93,66 @@
70 let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005); 93 let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
71 let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025); 94 let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
72 95
73 - let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0); 96 + let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
74 - let promedioPerCapita = $derived(datosHistorico?.per_capita || 0); 97 + let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
75 - let totalPorcentaje = $derived(datosHistorico?.prop || 0); 98 + let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
76 - let ranking = $derived(datosHistorico?.ranking || 0); 99 + let ranking = $derived(datosHistorico?.ranking ?? 0);
77 - let nPares = $derived(datosHistorico?.n_pares || 0); 100 + let nPares = $derived(datosHistorico?.n_pares ?? 0);
101 + let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
78 102
79 let currentData = $derived( 103 let currentData = $derived(
80 hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null 104 hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
81 ); 105 );
82 106
83 - let displayMonto = $derived(currentData ? currentData.monto : totalHistorico); 107 + // Datos del año hovereado desde datosAnuales (tiene todos los campos de la API)
84 - let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`); 108 + let currentAnual = $derived(hoveredYear ? datosAnuales.find(d => d.gestion === hoveredYear) : null);
85 - let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita); 109 +
86 - let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano'); 110 + // KPI 1: Monto
87 - let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje); 111 + let displayMonto = $derived.by(() => {
112 + if (selectedEntity) {
113 + return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
114 + }
115 + return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
116 + });
117 + let displayMontoLabel = $derived.by(() => {
118 + if (selectedEntity) {
119 + return currentAnual ? `gastados en ${currentAnual.gestion}` : `gastados ${primerAño}-${ultimoAño}`;
120 + }
121 + return currentAnual ? `gasto total del Estado en ${currentAnual.gestion}` : `gasto total ${primerAño}-${ultimoAño}`;
122 + });
123 +
124 + // KPI 2: Per cápita
125 + let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
126 + let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
127 +
128 + // KPI 3: Proporción (prop)
129 + let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
130 + let displayPorcentajeLabel = $derived.by(() => {
131 + if (selectedEntity) {
132 + return currentAnual ? `del total de esta partida en ${currentAnual.gestion}` : 'del total de esta partida';
133 + }
134 + return currentAnual ? `del gasto en ${currentAnual.gestion}` : 'del gasto total';
135 + });
136 +
88 let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`); 137 let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
89 - let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`); 138 + let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
90 - let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total'); 139 +
91 let nivelKey = $derived(objetoData.nivel?.toLowerCase()); 140 let nivelKey = $derived(objetoData.nivel?.toLowerCase());
92 let nivelPlural = $derived({ grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' }[nivelKey] || 'objetos'); 141 let nivelPlural = $derived({ grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' }[nivelKey] || 'objetos');
93 let nivelLabel = $derived({ grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }[nivelKey] || objetoData.nivel); 142 let nivelLabel = $derived({ grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }[nivelKey] || objetoData.nivel);
94 - let displayRankingLabel = $derived(currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`); 143 + let displayRankingLabel = $derived.by(() => {
144 + if (selectedEntity) {
145 + return currentAnual ? `entre las que más gastan en ${currentAnual.gestion}` : 'entre las que más gastan';
146 + }
147 + return currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`;
148 + });
95 149
96 // Top entidades 150 // Top entidades
97 let topEntidadesTotal = $derived( 151 let topEntidadesTotal = $derived(
98 datosHistorico && !selectedEntity ? [ 152 datosHistorico && !selectedEntity ? [
99 - { nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct }, 153 + { nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
100 - { nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct }, 154 + { nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
101 - { nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct }, 155 + { nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
102 ].filter(e => e.nombre) : [] 156 ].filter(e => e.nombre) : []
103 ); 157 );
104 let displayTopEntidades = $derived( 158 let displayTopEntidades = $derived(
...@@ -114,9 +168,9 @@ ...@@ -114,9 +168,9 @@
114 const twTop2 = tweened(0, tw); 168 const twTop2 = tweened(0, tw);
115 const twTop3 = tweened(0, tw); 169 const twTop3 = tweened(0, tw);
116 170
117 - $effect(() => { twMonto.set(displayMonto); }); 171 + $effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
118 - $effect(() => { twPerCapita.set(displayPerCapita); }); 172 + $effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
119 - $effect(() => { twPorcentaje.set(displayPorcentaje); }); 173 + $effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
120 $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); }); 174 $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
121 $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); }); 175 $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
122 $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); }); 176 $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
...@@ -133,38 +187,45 @@ ...@@ -133,38 +187,45 @@
133 } 187 }
134 188
135 async function loadData() { 189 async function loadData() {
190 + if (!selectedEntity) return;
136 loading = true; 191 loading = true;
137 - if (selectedEntity) { 192 + try {
138 - const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*') 193 + const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
139 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion'); 194 + const raw = await res.json();
140 - if (data) { 195 + console.log('[loadData] entidad:', selectedEntity.entidad, 'raw rows:', raw?.length);
196 + if (Array.isArray(raw)) {
197 + const seen = new Set();
198 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
141 datosHistorico = data.find(d => d.gestion === 0) || null; 199 datosHistorico = data.find(d => d.gestion === 0) || null;
142 datosAnuales = data.filter(d => d.gestion >= 2016); 200 datosAnuales = data.filter(d => d.gestion >= 2016);
143 topEntidadesPorAño = {}; 201 topEntidadesPorAño = {};
202 + console.log('[loadData] historico:', datosHistorico ? { monto: datosHistorico.monto, per_capita: datosHistorico.per_capita, prop: datosHistorico.prop } : null);
203 + // Forzar tweens directamente
204 + if (datosHistorico) {
205 + twMonto.set(datosHistorico.monto || 0);
206 + twPerCapita.set(datosHistorico.per_capita || 0);
207 + twPorcentaje.set(datosHistorico.prop || 0);
144 } 208 }
145 - } else {
146 - const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
147 - .eq('objeto', objetoCodigo).order('gestion');
148 - if (data) {
149 - datosHistorico = data.find(d => d.gestion === 0) || null;
150 - datosAnuales = data.filter(d => d.gestion >= 2016);
151 - topEntidadesPorAño = {};
152 - datosAnuales.forEach(d => {
153 - topEntidadesPorAño[d.gestion] = [
154 - { nombre: d.top1_entidad, porcentaje: d.top1_pct },
155 - { nombre: d.top2_entidad, porcentaje: d.top2_pct },
156 - { nombre: d.top3_entidad, porcentaje: d.top3_pct },
157 - ].filter(e => e.nombre);
158 - });
159 } 209 }
210 + } catch (err) {
211 + console.error('[loadData] Error:', err);
160 } 212 }
161 loading = false; 213 loading = false;
162 } 214 }
163 215
164 async function loadEntidades() { 216 async function loadEntidades() {
165 - const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc') 217 + try {
166 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc'); 218 + const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidades-lista`);
167 - if (data) entidades = data; 219 + const data = await res.json();
220 + if (Array.isArray(data)) {
221 + entidades = data
222 + .filter(d => d.gestion === 0)
223 + .map(d => ({ entidad: d.entidad, entidad_desc: d.desc_entidad }))
224 + .sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
225 + }
226 + } catch {
227 + entidades = [];
228 + }
168 } 229 }
169 230
170 // ══════════════════════════════════════════════════════════════ 231 // ══════════════════════════════════════════════════════════════
...@@ -206,11 +267,15 @@ ...@@ -206,11 +267,15 @@
206 }); 267 });
207 268
208 async function loadDatosEntidad(entidad) { 269 async function loadDatosEntidad(entidad) {
209 - const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*') 270 + try {
210 - .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', entidad.entidad).order('gestion'); 271 + const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
211 - if (data) { 272 + const raw = await res.json();
273 + if (Array.isArray(raw)) {
274 + const seen = new Set();
275 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
212 return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null }; 276 return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
213 } 277 }
278 + } catch {}
214 return { anual: [], historico: null }; 279 return { anual: [], historico: null };
215 } 280 }
216 281
...@@ -246,6 +311,7 @@ ...@@ -246,6 +311,7 @@
246 loadingComparacion = true; 311 loadingComparacion = true;
247 datosCompararA = await loadDatosEntidad(entidad); 312 datosCompararA = await loadDatosEntidad(entidad);
248 loadingComparacion = false; 313 loadingComparacion = false;
314 + updateCompararUrl();
249 } 315 }
250 316
251 async function selectEntidadB(entidad) { 317 async function selectEntidadB(entidad) {
...@@ -257,6 +323,7 @@ ...@@ -257,6 +323,7 @@
257 loadingComparacion = true; 323 loadingComparacion = true;
258 datosCompararB = await loadDatosEntidad(entidad); 324 datosCompararB = await loadDatosEntidad(entidad);
259 loadingComparacion = false; 325 loadingComparacion = false;
326 + updateCompararUrl();
260 } 327 }
261 328
262 function handleEntityScroll(event, type) { 329 function handleEntityScroll(event, type) {
...@@ -315,8 +382,13 @@ ...@@ -315,8 +382,13 @@
315 return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 }); 382 return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
316 }); 383 });
317 384
385 + // Valor según métrica seleccionada
386 + function valComparar(d) {
387 + return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
388 + }
389 +
318 let maxComparacion = $derived.by(() => { 390 let maxComparacion = $derived.by(() => {
319 - const all = [...datosGraficoA.map(d => d.per_capita || 0), ...datosGraficoB.map(d => d.per_capita || 0)]; 391 + const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
320 return Math.max(...all, 1) * 1.1; 392 return Math.max(...all, 1) * 1.1;
321 }); 393 });
322 394
...@@ -376,12 +448,28 @@ ...@@ -376,12 +448,28 @@
376 twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita)); 448 twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
377 }); 449 });
378 450
451 + // Actualizar URL para vista comparar
452 + function updateCompararUrl() {
453 + if (!mounted) return;
454 + const url = new URL($page.url);
455 + url.searchParams.set('vista', 'comparar');
456 + url.searchParams.delete('entidad');
457 + if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
458 + else url.searchParams.delete('entidadA');
459 + if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
460 + else url.searchParams.delete('entidadB');
461 + goto(url.toString(), { replaceState: true, noScroll: true });
462 + }
463 +
379 // Watch vista changes 464 // Watch vista changes
380 let previousVista = 'agregado'; 465 let previousVista = 'agregado';
381 $effect(() => { 466 $effect(() => {
382 if (mounted && vista !== previousVista) { 467 if (mounted && vista !== previousVista) {
383 previousVista = vista; 468 previousVista = vista;
384 - if (vista === 'comparar') initComparacion(); 469 + if (vista === 'comparar') {
470 + initComparacion();
471 + updateCompararUrl();
472 + }
385 } 473 }
386 }); 474 });
387 475
...@@ -397,31 +485,101 @@ ...@@ -397,31 +485,101 @@
397 onMount(() => { 485 onMount(() => {
398 mounted = true; 486 mounted = true;
399 initIndex(); 487 initIndex();
400 - // Read URL params 488 + loadEntidades().then(async () => {
489 + // Read URL params after entidades are loaded
401 const urlVista = $page.url.searchParams.get('vista'); 490 const urlVista = $page.url.searchParams.get('vista');
402 - if (urlVista === 'comparar') vista = 'comparar'; 491 + const urlEntidad = $page.url.searchParams.get('entidad');
492 + const urlEntidadA = $page.url.searchParams.get('entidadA');
493 + const urlEntidadB = $page.url.searchParams.get('entidadB');
494 +
495 + if (urlVista === 'comparar') {
496 + vista = 'comparar';
497 + // Precargar entidades de comparación desde URL
498 + if (urlEntidadA) {
499 + const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
500 + if (entA) {
501 + entidadCompararA = entA;
502 + loadingComparacion = true;
503 + datosCompararA = await loadDatosEntidad(entA);
504 + }
505 + }
506 + if (urlEntidadB) {
507 + const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
508 + if (entB) {
509 + entidadCompararB = entB;
510 + datosCompararB = await loadDatosEntidad(entB);
511 + }
512 + }
513 + loadingComparacion = false;
514 + if (!urlEntidadA && !urlEntidadB) initComparacion();
515 + } else if (urlEntidad && entidades.length > 0) {
516 + const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
517 + if (ent) selectedEntity = ent;
518 + }
519 + });
403 }); 520 });
404 521
405 - // Recargar entidades cuando cambia el objeto 522 + // Recargar cuando cambia el objeto (navegación entre partidas)
406 let prevCodigo = null; 523 let prevCodigo = null;
407 $effect(() => { 524 $effect(() => {
408 const code = objetoCodigo; 525 const code = objetoCodigo;
409 - if (code !== prevCodigo) { 526 + if (prevCodigo !== null && code !== prevCodigo) {
410 - prevCodigo = code; 527 + // Reiniciar comparación
411 - selectedEntity = null;
412 entidadCompararA = null; 528 entidadCompararA = null;
413 entidadCompararB = null; 529 entidadCompararB = null;
414 datosCompararA = { anual: [], historico: null }; 530 datosCompararA = { anual: [], historico: null };
415 datosCompararB = { anual: [], historico: null }; 531 datosCompararB = { anual: [], historico: null };
416 - loadEntidades(); 532 +
533 + // Cargar datos del estado desde objetoData.estados
534 + const { hist, anuales, topPorAño } = initEstados(objetoData);
535 + datosHistorico = hist;
536 + datosAnuales = anuales;
537 + topEntidadesPorAño = topPorAño;
538 + loading = false;
539 +
540 + // Recargar entidades y mantener la seleccionada si existe en la nueva partida
541 + const prevEntity = selectedEntity;
542 + loadEntidades().then(() => {
543 + if (prevEntity) {
544 + const match = entidades.find(e => e.entidad === prevEntity.entidad);
545 + if (match) {
546 + selectedEntity = match;
547 + loadData();
548 + } else {
549 + selectedEntity = null;
550 + }
417 } 551 }
418 }); 552 });
553 + }
554 + prevCodigo = code;
555 + });
419 556
420 - // Recargar datos cuando cambia el objeto o la entidad 557 + // Recargar cuando cambia la entidad seleccionada
558 + let prevEntity = null;
421 $effect(() => { 559 $effect(() => {
422 - const _code = objetoCodigo; 560 + const ent = selectedEntity;
423 - const _entity = selectedEntity; 561 + if (ent !== prevEntity) {
562 + prevEntity = ent;
563 + if (ent) {
424 loadData(); 564 loadData();
565 + } else if (prevCodigo) {
566 + const { hist, anuales, topPorAño } = initEstados(objetoData);
567 + datosHistorico = hist;
568 + datosAnuales = anuales;
569 + topEntidadesPorAño = topPorAño;
570 + loading = false;
571 + }
572 + // Actualizar URL
573 + if (mounted) {
574 + const url = new URL($page.url);
575 + if (ent) {
576 + url.searchParams.set('entidad', ent.entidad);
577 + } else {
578 + url.searchParams.delete('entidad');
579 + }
580 + goto(url.toString(), { replaceState: true, noScroll: true });
581 + }
582 + }
425 }); 583 });
426 </script> 584 </script>
427 585
...@@ -431,14 +589,6 @@ ...@@ -431,14 +589,6 @@
431 </svelte:head> 589 </svelte:head>
432 590
433 <div class="page" class:mounted> 591 <div class="page" class:mounted>
434 - <nav class="page-nav">
435 - <a href="/clasificadores/objeto-gasto" class="back-link">
436 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
437 - <path d="M19 12H5M12 19l-7-7 7-7"/>
438 - </svg>
439 - Clasificador
440 - </a>
441 - </nav>
442 592
443 {#if vista === 'agregado'} 593 {#if vista === 'agregado'}
444 <div class="dashboard-layout"> 594 <div class="dashboard-layout">
...@@ -458,7 +608,6 @@ ...@@ -458,7 +608,6 @@
458 <!-- Buscar --> 608 <!-- Buscar -->
459 <div class="context-section"> 609 <div class="context-section">
460 <h3 class="context-subtitle">Buscar</h3> 610 <h3 class="context-subtitle">Buscar</h3>
461 - <p class="context-hint">Buscar otro objeto de gasto</p>
462 <ObjetoSearch /> 611 <ObjetoSearch />
463 </div> 612 </div>
464 613
...@@ -515,13 +664,6 @@ ...@@ -515,13 +664,6 @@
515 <span class="explore-title">Comparar dos entidades</span> 664 <span class="explore-title">Comparar dos entidades</span>
516 <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span> 665 <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
517 </div> 666 </div>
518 - <a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
519 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
520 - <path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
521 - </svg>
522 - <span class="explore-title">Comparar partidas</span>
523 - <span class="explore-desc">Todas las partidas entre dos momentos o entidades</span>
524 - </a>
525 <a href="/objeto/{objetoData.objeto}/entidades" class="explore-card"> 667 <a href="/objeto/{objetoData.objeto}/entidades" class="explore-card">
526 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75"> 668 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
527 <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/> 669 <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
...@@ -529,7 +671,7 @@ ...@@ -529,7 +671,7 @@
529 <span class="explore-title">Ranking</span> 671 <span class="explore-title">Ranking</span>
530 <span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span> 672 <span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
531 </a> 673 </a>
532 - <a href="/ubicacion" class="explore-card explore-card-disabled"> 674 + <a href="/ubicacion?clas=objeto&codigo={objetoData.objeto}" class="explore-card">
533 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75"> 675 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
534 <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"/> 676 <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"/>
535 </svg> 677 </svg>
...@@ -543,6 +685,13 @@ ...@@ -543,6 +685,13 @@
543 <span class="explore-title">Peso relativo</span> 685 <span class="explore-title">Peso relativo</span>
544 <span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span> 686 <span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
545 </a> 687 </a>
688 + <a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
689 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
690 + <path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
691 + </svg>
692 + <span class="explore-title">Comparar partidas</span>
693 + <span class="explore-desc">Todas las partidas entre dos momentos o entidades</span>
694 + </a>
546 <a href="/clasificadores/objeto-gasto" class="explore-card"> 695 <a href="/clasificadores/objeto-gasto" class="explore-card">
547 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75"> 696 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
548 <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/> 697 <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
...@@ -577,17 +726,44 @@ ...@@ -577,17 +726,44 @@
577 <!-- Definición --> 726 <!-- Definición -->
578 <div class="context-section"> 727 <div class="context-section">
579 <h3 class="context-subtitle">Definición</h3> 728 <h3 class="context-subtitle">Definición</h3>
729 + {#if variaciones.length <= 1}
730 + <p class="context-hint">Definición estable en todo el período</p>
731 + {:else}
732 + <p class="context-hint">El significado de este gasto ha cambiado {variaciones.length} veces en el período</p>
733 + {/if}
580 <button class="def-link" onclick={() => showDefinicionesModal = true}> 734 <button class="def-link" onclick={() => showDefinicionesModal = true}>
581 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 735 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
582 <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"/> 736 <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"/>
583 </svg> 737 </svg>
584 - {#if variaciones.length === 1} 738 + {#if variaciones.length <= 1}
585 - Ver definición oficial 739 + Ver definición
586 {:else} 740 {:else}
587 - Ver {variaciones.length} variaciones en la definición 741 + Ver variaciones
588 {/if} 742 {/if}
589 </button> 743 </button>
590 </div> 744 </div>
745 +
746 + <div class="context-separator"></div>
747 +
748 + <!-- Compartir -->
749 + <div class="context-section">
750 + <h3 class="context-subtitle">Compartir</h3>
751 + <button class="copy-link-btn" onclick={() => {
752 + navigator.clipboard.writeText(window.location.href);
753 + linkCopied = true;
754 + setTimeout(() => linkCopied = false, 2000);
755 + }}>
756 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
757 + {#if linkCopied}
758 + <path d="M20 6L9 17l-5-5"/>
759 + {:else}
760 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
761 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
762 + {/if}
763 + </svg>
764 + <span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
765 + </button>
766 + </div>
591 </div> 767 </div>
592 </aside> 768 </aside>
593 769
...@@ -606,22 +782,40 @@ ...@@ -606,22 +782,40 @@
606 782
607 <div class="chart-controls"> 783 <div class="chart-controls">
608 <EntitySelector {entidades} bind:selectedEntity {nEntidades} /> 784 <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
785 + {#if selectedEntity}
786 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
787 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
788 + <path d="M19 12H5M12 19l-7-7 7-7"/>
789 + </svg>
790 + Todo el Estado
791 + </button>
792 + {/if}
609 </div> 793 </div>
610 794
611 <div class="chart-header"> 795 <div class="chart-header">
612 <span class="chart-title"> 796 <span class="chart-title">
797 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
613 {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'} 798 {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
799 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
800 + </button>
614 <span class="chart-period">· {displayPeriodo}</span> 801 <span class="chart-period">· {displayPeriodo}</span>
615 </span> 802 </span>
616 - <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
617 - {metrica === 'percapita' ? 'Ver monto real' : 'Ver per cápita'}
618 - </button>
619 </div> 803 </div>
620 804
621 {#if loading} 805 {#if loading}
622 <div class="chart-loading" style="flex: 1; min-height: 100px;"> 806 <div class="chart-loading" style="flex: 1; min-height: 100px;">
623 <span>Cargando...</span> 807 <span>Cargando...</span>
624 </div> 808 </div>
809 + {:else if gastoPerCapita.length === 0 && selectedEntity}
810 + <div class="chart-no-data">
811 + <p>Esta entidad no tiene registros en {objetoData.desc_objeto?.toLowerCase()} desde 2016</p>
812 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
813 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
814 + <path d="M19 12H5M12 19l-7-7 7-7"/>
815 + </svg>
816 + Volver a Todo el Estado
817 + </button>
818 + </div>
625 {:else} 819 {:else}
626 <div class="chart-flex-wrapper"> 820 <div class="chart-flex-wrapper">
627 <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} /> 821 <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
...@@ -651,16 +845,25 @@ ...@@ -651,16 +845,25 @@
651 845
652 {#if !selectedEntity && displayTopEntidades.length > 0} 846 {#if !selectedEntity && displayTopEntidades.length > 0}
653 <div class="ranking-section"> 847 <div class="ranking-section">
848 + <div class="ranking-header">
654 <h3 class="ranking-title">Concentración del gasto</h3> 849 <h3 class="ranking-title">Concentración del gasto</h3>
655 - <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> 850 + <div class="ranking-summary">
851 + <span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
852 + <span class="ranking-summary-text">de {objetoData.desc_objeto?.toLowerCase()} lo gastan estas 3 entidades{hoveredYear ? ` en ${hoveredYear}` : ''}</span>
853 + </div>
854 + </div>
656 <div class="ranking-cards"> 855 <div class="ranking-cards">
657 {#each displayTopEntidades as ent, i} 856 {#each displayTopEntidades as ent, i}
658 {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3} 857 {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
659 - <div class="ranking-card"> 858 + <button class="ranking-card ranking-card-link" onclick={() => {
859 + const match = entidades.find(e => e.entidad === ent.entidad);
860 + if (match) selectedEntity = match;
861 + }}>
660 <span class="ranking-position">#{i + 1}</span> 862 <span class="ranking-position">#{i + 1}</span>
661 <span class="ranking-name">{ent.nombre}</span> 863 <span class="ranking-name">{ent.nombre}</span>
662 <span class="ranking-pct">{pct.toFixed(1)}%</span> 864 <span class="ranking-pct">{pct.toFixed(1)}%</span>
663 - </div> 865 + <svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
866 + </button>
664 {/each} 867 {/each}
665 </div> 868 </div>
666 </div> 869 </div>
...@@ -783,7 +986,28 @@ ...@@ -783,7 +986,28 @@
783 <div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div> 986 <div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
784 {:else} 987 {:else}
785 <div class="chart-header"> 988 <div class="chart-header">
786 - <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodoComparar}</span></span> 989 + <span class="chart-title">
990 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
991 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
992 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
993 + </button>
994 + <span class="chart-period">· {displayPeriodoComparar}</span>
995 + </span>
996 + <button class="copy-link-btn" onclick={() => {
997 + navigator.clipboard.writeText(window.location.href);
998 + linkCopied = true;
999 + setTimeout(() => linkCopied = false, 2000);
1000 + }}>
1001 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1002 + {#if linkCopied}
1003 + <path d="M20 6L9 17l-5-5"/>
1004 + {:else}
1005 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
1006 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
1007 + {/if}
1008 + </svg>
1009 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
1010 + </button>
787 </div> 1011 </div>
788 1012
789 <div class="chart-comparar-wrapper"> 1013 <div class="chart-comparar-wrapper">
...@@ -798,8 +1022,8 @@ ...@@ -798,8 +1022,8 @@
798 {#each añosComparacion as año, i} 1022 {#each añosComparacion as año, i}
799 <div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0"> 1023 <div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
800 <div class="bars-pair"> 1024 <div class="bars-pair">
801 - <div class="bar-comparar" style="height: {((datosGraficoA[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorA}"></div> 1025 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
802 - <div class="bar-comparar" style="height: {((datosGraficoB[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorB}"></div> 1026 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
803 </div> 1027 </div>
804 </div> 1028 </div>
805 {/each} 1029 {/each}
...@@ -910,8 +1134,8 @@ ...@@ -910,8 +1134,8 @@
910 gap: 1.5rem; 1134 gap: 1.5rem;
911 max-width: 1400px; 1135 max-width: 1400px;
912 margin: 0 auto; 1136 margin: 0 auto;
913 - padding: 30px 2rem; 1137 + padding: 80px 2rem 2.5rem;
914 - height: calc(100vh - 4rem); 1138 + height: 100vh;
915 overflow: hidden; 1139 overflow: hidden;
916 } 1140 }
917 1141
...@@ -1089,6 +1313,7 @@ ...@@ -1089,6 +1313,7 @@
1089 background: var(--theme-borde); 1313 background: var(--theme-borde);
1090 text-decoration: none; 1314 text-decoration: none;
1091 color: var(--theme-texto); 1315 color: var(--theme-texto);
1316 + cursor: pointer;
1092 transition: all 0.15s; 1317 transition: all 0.15s;
1093 flex-shrink: 0; 1318 flex-shrink: 0;
1094 overflow: hidden; 1319 overflow: hidden;
...@@ -1199,6 +1424,53 @@ ...@@ -1199,6 +1424,53 @@
1199 .def-link:hover { opacity: 0.7; } 1424 .def-link:hover { opacity: 0.7; }
1200 .def-link svg { flex-shrink: 0; opacity: 0.8; } 1425 .def-link svg { flex-shrink: 0; opacity: 0.8; }
1201 1426
1427 + .chart-no-data {
1428 + flex: 1;
1429 + display: flex;
1430 + flex-direction: column;
1431 + align-items: center;
1432 + justify-content: center;
1433 + gap: 0.75rem;
1434 + color: var(--theme-texto);
1435 + opacity: 0.7;
1436 + font-size: 0.8125rem;
1437 + text-align: center;
1438 + }
1439 +
1440 + .chart-no-data p {
1441 + margin: 0;
1442 + }
1443 +
1444 + .volver-agregado {
1445 + display: inline-flex;
1446 + align-items: center;
1447 + gap: 0.25rem;
1448 + font-size: 0.6875rem;
1449 + color: var(--theme-accent);
1450 + background: none;
1451 + border: none;
1452 + padding: 0;
1453 + cursor: pointer;
1454 + transition: opacity 0.15s;
1455 + }
1456 + .volver-agregado:hover { opacity: 0.7; }
1457 +
1458 + .copy-link-btn {
1459 + display: flex;
1460 + align-items: center;
1461 + gap: 0.375rem;
1462 + font-size: 0.75rem;
1463 + color: var(--theme-texto);
1464 + opacity: 0.6;
1465 + background: none;
1466 + border: none;
1467 + padding: 0;
1468 + cursor: pointer;
1469 + transition: opacity 0.15s;
1470 + }
1471 + .copy-link-btn:hover { opacity: 1; }
1472 + .copy-link-btn svg { flex-shrink: 0; }
1473 +
1202 /* ═══════════════════════════════════════════════════════════════ 1474 /* ═══════════════════════════════════════════════════════════════
1203 MAIN 1475 MAIN
1204 ═══════════════════════════════════════════════════════════════ */ 1476 ═══════════════════════════════════════════════════════════════ */
...@@ -1297,18 +1569,23 @@ ...@@ -1297,18 +1569,23 @@
1297 1569
1298 .metrica-toggle { 1570 .metrica-toggle {
1299 font-family: 'DM Mono', monospace; 1571 font-family: 'DM Mono', monospace;
1300 - font-size: 0.625rem; 1572 + font-size: 0.6875rem;
1301 - color: var(--theme-accent); 1573 + color: var(--theme-texto);
1302 - background: rgba(201, 167, 81, 0.1); 1574 + background: rgba(128, 128, 128, 0.08);
1303 - border: 1px solid rgba(201, 167, 81, 0.2); 1575 + border: 1px solid rgba(128, 128, 128, 0.15);
1304 - padding: 0.2rem 0.5rem; 1576 + padding: 0.125rem 0.375rem;
1305 border-radius: 4px; 1577 border-radius: 4px;
1306 cursor: pointer; 1578 cursor: pointer;
1307 transition: all 0.15s; 1579 transition: all 0.15s;
1580 + display: inline-flex;
1581 + align-items: center;
1582 + gap: 0.25rem;
1308 } 1583 }
1309 1584
1310 .metrica-toggle:hover { 1585 .metrica-toggle:hover {
1311 - background: rgba(201, 167, 81, 0.2); 1586 + background: rgba(201, 167, 81, 0.12);
1587 + border-color: rgba(201, 167, 81, 0.3);
1588 + color: var(--theme-accent);
1312 } 1589 }
1313 1590
1314 .chart-loading { 1591 .chart-loading {
...@@ -1381,10 +1658,62 @@ ...@@ -1381,10 +1658,62 @@
1381 .ranking-card { 1658 .ranking-card {
1382 display: flex; 1659 display: flex;
1383 align-items: center; 1660 align-items: center;
1384 - gap: 0.75rem; 1661 + gap: 0.375rem;
1385 padding: 0.5rem 0.625rem; 1662 padding: 0.5rem 0.625rem;
1386 border-radius: 8px; 1663 border-radius: 8px;
1387 - background: rgba(128, 128, 128, 0.06); 1664 + background: transparent;
1665 + text-align: left;
1666 + }
1667 +
1668 + .ranking-card-link {
1669 + text-decoration: none;
1670 + cursor: pointer;
1671 + transition: background 0.15s;
1672 + }
1673 +
1674 + .ranking-card-link:hover {
1675 + background: rgba(201, 167, 81, 0.05);
1676 + }
1677 +
1678 + .ranking-card-link:hover .ranking-name {
1679 + color: var(--theme-accent);
1680 + border-bottom-style: solid;
1681 + }
1682 +
1683 + .ranking-arrow {
1684 + opacity: 0;
1685 + color: var(--theme-accent);
1686 + transition: opacity 0.15s, transform 0.15s;
1687 + flex-shrink: 0;
1688 + }
1689 +
1690 + .ranking-card-link:hover .ranking-arrow {
1691 + opacity: 1;
1692 + transform: translateX(2px);
1693 + }
1694 +
1695 + .ranking-header {
1696 + margin-bottom: 0.5rem;
1697 + }
1698 +
1699 + .ranking-summary {
1700 + display: flex;
1701 + align-items: baseline;
1702 + gap: 0.375rem;
1703 + margin-top: 0.125rem;
1704 + }
1705 +
1706 + .ranking-summary-pct {
1707 + font-family: 'DM Mono', monospace;
1708 + font-size: 1.25rem;
1709 + font-weight: 700;
1710 + color: var(--theme-titulo);
1711 + }
1712 +
1713 + .ranking-summary-text {
1714 + font-size: 0.75rem;
1715 + color: var(--theme-texto);
1716 + opacity: 0.7;
1388 } 1717 }
1389 1718
1390 .ranking-position { 1719 .ranking-position {
...@@ -1396,19 +1725,17 @@ ...@@ -1396,19 +1725,17 @@
1396 } 1725 }
1397 1726
1398 .ranking-name { 1727 .ranking-name {
1399 - flex: 1;
1400 font-size: 0.8125rem; 1728 font-size: 0.8125rem;
1401 color: var(--theme-titulo); 1729 color: var(--theme-titulo);
1402 - white-space: nowrap; 1730 + border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
1403 - overflow: hidden;
1404 - text-overflow: ellipsis;
1405 } 1731 }
1406 1732
1407 .ranking-pct { 1733 .ranking-pct {
1408 font-family: 'DM Mono', monospace; 1734 font-family: 'DM Mono', monospace;
1409 - font-size: 0.875rem; 1735 + font-size: 0.8125rem;
1410 font-weight: 600; 1736 font-weight: 600;
1411 color: var(--theme-titulo); 1737 color: var(--theme-titulo);
1738 + opacity: 0.7;
1412 } 1739 }
1413 1740
1414 /* ═══════════════════════════════════════════════════════════════ 1741 /* ═══════════════════════════════════════════════════════════════
...@@ -1707,11 +2034,12 @@ ...@@ -1707,11 +2034,12 @@
1707 display: flex; 2034 display: flex;
1708 flex-direction: column; 2035 flex-direction: column;
1709 gap: 0.75rem; 2036 gap: 0.75rem;
1710 - padding: 0.75rem 2rem 1.5rem; 2037 + padding: 80px 2rem 2.5rem;
1711 width: 100%; 2038 width: 100%;
1712 max-width: 1400px; 2039 max-width: 1400px;
1713 margin: 0 auto; 2040 margin: 0 auto;
1714 - min-height: calc(100vh - 60px); 2041 + height: 100vh;
2042 + overflow: hidden;
1715 } 2043 }
1716 2044
1717 .chart-card-comparar { 2045 .chart-card-comparar {
......
1 <script> 1 <script>
2 import { page } from '$app/stores'; 2 import { page } from '$app/stores';
3 import { goto } from '$app/navigation'; 3 import { goto } from '$app/navigation';
4 - import { supabase } from '$lib/supabase';
5 import { onMount } from 'svelte'; 4 import { onMount } from 'svelte';
5 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 import * as d3 from 'd3'; 6 import * as d3 from 'd3';
7 7
8 - // Params y query
9 let codigo = $derived($page.params.codigo); 8 let codigo = $derived($page.params.codigo);
10 - let gestion = $derived($page.url.searchParams.get('gestion') || new Date().getFullYear().toString());
11 9
12 // Estado 10 // Estado
13 let loading = $state(true); 11 let loading = $state(true);
14 - let objetoInfo = $state(null); // Info del objeto (desc_objeto, nivel) 12 + let mounted = $state(false);
13 + let objetoInfo = $state(null);
15 let entidadesData = $state([]); 14 let entidadesData = $state([]);
16 - let availableYears = $state([]);
17 let selectedYear = $state(null); 15 let selectedYear = $state(null);
16 + let linkCopied = $state(false);
17 + let hoveredNode = $state(null);
18 + let totalDevengado = $state(0);
19 + let entidadClasificador = $state({}); // Map entidad → { desc_subarea, sigla_subarea, desc_area, ... }
20 + let subareas = $state([]); // Lista única de subareas con color
21 + let sliderMin = $state(0);
22 + let sliderMax = $state(100);
23 +
24 + // Filtrar entidades por rango de % acumulado
25 + let filteredEntidades = $derived.by(() => {
26 + if (sliderMin === 0 && sliderMax === 100) return entidadesData;
27 + let acum = 0;
28 + return entidadesData.filter(d => {
29 + const pctBefore = (acum / totalDevengado) * 100;
30 + acum += d.monto;
31 + const pctAfter = (acum / totalDevengado) * 100;
32 + return pctAfter > sliderMin && pctBefore < sliderMax;
33 + });
34 + });
35 +
36 + let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
37 + let filteredCount = $derived(filteredEntidades.length);
18 38
19 // Treemap 39 // Treemap
20 let treemapContainer = $state(null); 40 let treemapContainer = $state(null);
21 let treemapWidth = $state(0); 41 let treemapWidth = $state(0);
22 - let treemapHeight = $state(500); 42 + let treemapHeight = $state(0);
23 let treemapNodes = $state([]); 43 let treemapNodes = $state([]);
24 - let treemapRoot = $state(null); 44 + let treemapGroups = $state([]); // Nodos padre (subareas)
25 - let hoveredNode = $state(null);
26 - let totalDevengado = $state(0);
27 45
28 - // Población para per cápita 46 + // Años disponibles desde gestiones del objeto
29 - const POBLACION_BOLIVIA = 12006031; 47 + let availableYears = $derived.by(() => {
48 + if (!objetoInfo?.gestiones) return [];
49 + return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a);
50 + });
30 51
31 // Formatters 52 // Formatters
32 - function formatMoney(value) { 53 + function formatMonto(v) {
33 - if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`; 54 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
34 - if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`; 55 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
35 - if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`; 56 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
36 - return `Bs ${value.toFixed(0)}`; 57 + return v?.toLocaleString('es-BO') || '0';
37 } 58 }
38 59
39 - function formatMoneyCompact(value) { 60 + function formatCompact(v) {
40 - if (value >= 1e9) return `${(value / 1e9).toFixed(1)}MM`; 61 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
41 - if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`; 62 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
42 - if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`; 63 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
43 - return value.toFixed(0); 64 + return v?.toFixed(0) || '0';
44 } 65 }
45 66
46 - function formatPerCapita(value) { 67 + // Colores por subarea — misma paleta que clasificador objeto-gasto
47 - const perCapita = value / POBLACION_BOLIVIA; 68 + let isDark = $state(false);
48 - return `Bs ${perCapita.toFixed(0)}/hab`; 69 + const SUBAREA_COLORS = [
49 - } 70 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
71 + '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
72 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
73 + ];
50 74
51 - // Colores para entidades (gradiente basado en ranking) 75 + function getSubareaColor(subarea) {
52 - function getEntityColor(index, total, opacity = 0.85) { 76 + const idx = subareas.indexOf(subarea);
53 - // Gradiente de más oscuro (top) a más claro (bottom) 77 + return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
54 - const hue = 210; // Azul
55 - const saturation = 60 + (index / total) * 20;
56 - const lightness = 35 + (index / total) * 30;
57 - return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`;
58 } 78 }
59 79
60 - function getTextColor(index, total) { 80 + // Contraste de texto según luminosidad del fondo (WCAG)
61 - return index < total * 0.6 ? '#ffffff' : '#1a1a1a'; 81 + function getTextColorForBg(bgColor) {
82 + const color = d3.color(bgColor);
83 + if (!color) return 'rgba(255,255,255,0.95)';
84 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
85 + return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
62 } 86 }
63 87
64 - // Cargar años disponibles 88 + function getTextColorSecondary(bgColor) {
65 - async function loadAvailableYears() { 89 + const color = d3.color(bgColor);
66 - const { data, error } = await supabase 90 + if (!color) return 'rgba(255,255,255,0.88)';
67 - .schema('ppto') 91 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
68 - .from('treemap_objeto') 92 + return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
69 - .select('gestion') 93 + }
70 - .eq('objeto', parseInt(codigo))
71 - .gt('devengado', 0);
72 -
73 - if (!error && data) {
74 - const years = [...new Set(data.map(d => d.gestion))].sort((a, b) => b - a);
75 - availableYears = years;
76 94
77 - // Usar año de query param o el más reciente 95 + // Cargar clasificador de entidades
78 - const yearParam = parseInt(gestion); 96 + async function loadClasificador() {
79 - selectedYear = years.includes(yearParam) ? yearParam : years[0]; 97 + try {
98 + const res = await fetch('/api/entidad-clasificador');
99 + const data = await res.json();
100 + if (Array.isArray(data)) {
101 + const map = {};
102 + data.forEach(d => { map[d.entidad] = d; });
103 + entidadClasificador = map;
80 } 104 }
105 + } catch {}
81 } 106 }
82 107
83 - // Cargar datos de entidades para el objeto 108 + // Cargar info del objeto desde API
84 - async function loadEntityData() { 109 + async function loadObjeto() {
85 - if (!selectedYear || !codigo) return; 110 + try {
111 + const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=clasificador`);
112 + const data = await res.json();
113 + if (data && !data.error) objetoInfo = data;
114 + } catch {}
115 + }
86 116
117 + // Cargar entidades desde API
118 + async function loadEntidades() {
119 + if (!selectedYear || !codigo) return;
87 loading = true; 120 loading = true;
88 -
89 try { 121 try {
90 - // Obtener info del objeto 122 + const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
91 - const { data: infoData } = await supabase 123 + const raw = await res.json();
92 - .schema('ppto') 124 + if (Array.isArray(raw)) {
93 - .from('treemap_objeto') 125 + // Deduplicar por entidad
94 - .select('desc_objeto, nivel') 126 + const map = new Map();
95 - .eq('objeto', parseInt(codigo)) 127 + raw.forEach(d => {
96 - .eq('gestion', selectedYear) 128 + if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
97 - .limit(1) 129 + map.set(d.entidad, d);
98 - .single();
99 -
100 - if (infoData) {
101 - objetoInfo = infoData;
102 - }
103 -
104 - // Obtener distribución por entidades (excluyendo entidad 0=total y 99=otros)
105 - const { data, error } = await supabase
106 - .schema('ppto')
107 - .from('treemap_objeto')
108 - .select('entidad, desc_entidad, devengado')
109 - .eq('objeto', parseInt(codigo))
110 - .eq('gestion', selectedYear)
111 - .neq('entidad', 0)
112 - .neq('entidad', 99)
113 - .gt('devengado', 0)
114 - .order('devengado', { ascending: false });
115 -
116 - if (error) {
117 - console.error('Error loading entity data:', error);
118 - return;
119 } 130 }
120 - 131 + });
121 - entidadesData = data || []; 132 + entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
122 - totalDevengado = entidadesData.reduce((sum, d) => sum + (d.devengado || 0), 0); 133 + totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
134 +
135 + // Nombres desde map endpoint
136 + const mapRes = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=entidades-lista`);
137 + const mapData = await mapRes.json();
138 + if (Array.isArray(mapData)) {
139 + const nombres = {};
140 + mapData.forEach(d => { if (d.desc_entidad) nombres[d.entidad] = d.desc_entidad; });
141 + entidadesData = entidadesData.map(d => ({
142 + ...d,
143 + desc_entidad: nombres[d.entidad] || d.desc_entidad || `Entidad ${d.entidad}`,
144 + desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
145 + }));
146 + }
147 +
148 + // Extraer subareas únicas ordenadas por monto total
149 + const subareaMontos = {};
150 + entidadesData.forEach(d => {
151 + subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
152 + });
153 + subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
123 154
124 buildTreemap(); 155 buildTreemap();
156 + }
125 } catch (err) { 157 } catch (err) {
126 - console.error('Error:', err); 158 + console.error('[Entidades] Error:', err);
127 - } finally {
128 - loading = false;
129 } 159 }
160 + loading = false;
130 } 161 }
131 162
132 - // Construir treemap
133 function buildTreemap() { 163 function buildTreemap() {
134 - if (!entidadesData.length || treemapWidth <= 0) return; 164 + if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
165 + treemapNodes = [];
166 + treemapGroups = [];
167 + return;
168 + }
135 169
136 - const hierarchy = d3.hierarchy({ 170 + // Agrupar por subarea
137 - name: 'root', 171 + const groups = {};
138 - children: entidadesData.map((d, i) => ({ 172 + filteredEntidades.forEach(d => {
173 + const sub = d.desc_subarea || 'Otros';
174 + if (!groups[sub]) groups[sub] = [];
175 + groups[sub].push({
139 id: d.entidad, 176 id: d.entidad,
140 name: d.desc_entidad || `Entidad ${d.entidad}`, 177 name: d.desc_entidad || `Entidad ${d.entidad}`,
141 - value: d.devengado, 178 + value: d.monto,
142 - index: i 179 + ranking: d.ranking,
180 + per_capita: d.per_capita,
181 + prop: d.prop,
182 + subarea: sub
183 + });
184 + });
185 +
186 + const hierarchy = d3.hierarchy({
187 + name: 'root',
188 + children: Object.entries(groups).map(([subarea, children]) => ({
189 + name: subarea,
190 + children
143 })) 191 }))
144 }).sum(d => d.value || 0) 192 }).sum(d => d.value || 0)
145 .sort((a, b) => b.value - a.value); 193 .sort((a, b) => b.value - a.value);
146 194
147 - const treemap = d3.treemap() 195 + d3.treemap()
148 .size([treemapWidth, treemapHeight]) 196 .size([treemapWidth, treemapHeight])
149 - .padding(2) 197 + .paddingOuter(3)
150 - .round(true); 198 + .paddingInner(1)
151 - 199 + .paddingTop(18)
152 - treemap(hierarchy); 200 + .round(true)(hierarchy);
153 - treemapRoot = hierarchy;
154 - treemapNodes = hierarchy.children || [];
155 - }
156 -
157 - // Resize observer
158 - function setupResizeObserver() {
159 - if (!treemapContainer) return;
160 -
161 - const observer = new ResizeObserver(entries => {
162 - for (const entry of entries) {
163 - treemapWidth = entry.contentRect.width;
164 - buildTreemap();
165 - }
166 - });
167 -
168 - observer.observe(treemapContainer);
169 - return () => observer.disconnect();
170 - }
171 201
172 - // Navegación 202 + treemapNodes = hierarchy.leaves() || [];
173 - function goBack() { 203 + treemapGroups = hierarchy.children || [];
174 - goto(`/clasificadores/objeto-gasto?modo=mapa`);
175 } 204 }
176 205
177 function changeYear(year) { 206 function changeYear(year) {
178 selectedYear = year; 207 selectedYear = year;
179 goto(`?gestion=${year}`, { replaceState: true, noScroll: true }); 208 goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
209 + loadEntidades();
180 } 210 }
181 211
182 - // Efectos
183 $effect(() => { 212 $effect(() => {
184 - if (codigo) { 213 + if (treemapContainer) {
185 - loadAvailableYears(); 214 + const measure = () => {
215 + const w = treemapContainer.clientWidth;
216 + const h = treemapContainer.clientHeight;
217 + if (w > 0 && h > 0) {
218 + treemapWidth = w;
219 + treemapHeight = h;
220 + buildTreemap();
186 } 221 }
187 - }); 222 + };
188 - 223 + measure();
189 - $effect(() => { 224 + const observer = new ResizeObserver(measure);
190 - if (selectedYear && codigo) { 225 + observer.observe(treemapContainer);
191 - loadEntityData(); 226 + return () => observer.disconnect();
192 } 227 }
193 }); 228 });
194 229
195 - $effect(() => { 230 +
196 - if (treemapContainer) { 231 + onMount(async () => {
197 - return setupResizeObserver(); 232 + mounted = true;
198 - } 233 + isDark = document.documentElement.classList.contains('dark');
234 + const observer = new MutationObserver(() => {
235 + isDark = document.documentElement.classList.contains('dark');
199 }); 236 });
237 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
200 238
201 - $effect(() => { 239 + await Promise.all([loadObjeto(), loadClasificador()]);
202 - if (entidadesData.length && treemapWidth > 0) { 240 +
203 - buildTreemap(); 241 + const yearParam = $page.url.searchParams.get('gestion');
242 + if (yearParam) {
243 + const y = parseInt(yearParam);
244 + selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
245 + } else {
246 + selectedYear = availableYears[0] || 2025;
204 } 247 }
205 - });
206 248
207 - onMount(() => {
208 if (treemapContainer) { 249 if (treemapContainer) {
209 treemapWidth = treemapContainer.clientWidth; 250 treemapWidth = treemapContainer.clientWidth;
251 + treemapHeight = treemapContainer.clientHeight;
210 } 252 }
253 + await loadEntidades();
211 }); 254 });
212 </script> 255 </script>
213 256
214 <svelte:head> 257 <svelte:head>
215 - <title>{objetoInfo?.desc_objeto || `Objeto ${codigo}`} - Distribución por Entidades</title> 258 + <title>{objetoInfo?.desc_objeto || `Objeto ${codigo}`} - Ranking de Entidades</title>
259 + <link rel="preconnect" href="https://fonts.googleapis.com" />
260 + <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" />
216 </svelte:head> 261 </svelte:head>
217 262
218 -<div class="page-container"> 263 +<div class="page" class:mounted>
264 + <div class="layout">
219 <!-- Header --> 265 <!-- Header -->
220 - <header class="page-header"> 266 + <header class="header">
221 - <button class="back-btn" onclick={goBack}> 267 + <div class="header-top">
222 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 268 + <div class="pills">
223 - <path d="M19 12H5M12 19l-7-7 7-7"/> 269 + <span class="pill pill-nivel">{objetoInfo?.nivel || 'objeto'}</span>
270 + <span class="pill pill-codigo">{codigo}</span>
271 + </div>
272 + <div class="header-actions">
273 + <button class="copy-link-btn" onclick={() => {
274 + navigator.clipboard.writeText(window.location.href);
275 + linkCopied = true;
276 + setTimeout(() => linkCopied = false, 2000);
277 + }}>
278 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
279 + {#if linkCopied}
280 + <path d="M20 6L9 17l-5-5"/>
281 + {:else}
282 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
283 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
284 + {/if}
224 </svg> 285 </svg>
225 - Volver al mapa 286 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
226 </button> 287 </button>
227 - 288 + <a href="/objeto/{codigo}" class="back-link">
228 - <div class="header-content"> 289 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
229 - <div class="header-badge">{objetoInfo?.nivel || 'objeto'}</div> 290 + <path d="M19 12H5M12 19l-7-7 7-7"/>
230 - <h1 class="header-title"> 291 + </svg>
231 - <span class="header-code">{codigo}</span> 292 + Volver
232 - <span class="header-separator">·</span> 293 + </a>
233 - <span class="header-name">{objetoInfo?.desc_objeto || 'Cargando...'}</span> 294 + </div>
234 - </h1>
235 - <p class="header-subtitle">¿Qué entidades gastan más en esto?</p>
236 </div> 295 </div>
237 296
238 - <!-- Selector de año --> 297 + <h1 class="title">{objetoInfo?.desc_objeto || 'Cargando...'}</h1>
298 + <p class="subtitle">¿Qué entidades gastan más en esto?</p>
299 +
300 + <!-- Año + Stats -->
301 + <div class="meta-row">
239 <div class="year-selector"> 302 <div class="year-selector">
240 - <label>Gestión:</label>
241 - <select bind:value={selectedYear} onchange={(e) => changeYear(parseInt(e.target.value))}>
242 {#each availableYears as year} 303 {#each availableYears as year}
243 - <option value={year}>{year}</option> 304 + <button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
244 {/each} 305 {/each}
245 - </select>
246 </div> 306 </div>
247 - </header>
248 -
249 - <!-- Stats resumen -->
250 {#if !loading && entidadesData.length > 0} 307 {#if !loading && entidadesData.length > 0}
251 - <div class="stats-bar"> 308 + <div class="stats">
252 - <div class="stat"> 309 + <span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
253 - <span class="stat-value">{entidadesData.length}</span>
254 - <span class="stat-label">entidades</span>
255 </div> 310 </div>
256 - <div class="stat"> 311 + {/if}
257 - <span class="stat-value">{formatMoney(totalDevengado)}</span>
258 - <span class="stat-label">total ejecutado</span>
259 </div> 312 </div>
260 - <div class="stat"> 313 +
261 - <span class="stat-value">{formatPerCapita(totalDevengado)}</span> 314 + <!-- Slider de rango -->
262 - <span class="stat-label">per cápita</span> 315 + {#if !loading && entidadesData.length > 0}
316 + <div class="slider-row">
317 + <span class="slider-label">Rango: {sliderMin}% – {sliderMax}%</span>
318 + <span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
263 </div> 319 </div>
320 + <div class="range-slider">
321 + <input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
322 + <input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
264 </div> 323 </div>
265 {/if} 324 {/if}
325 + </header>
266 326
267 <!-- Treemap --> 327 <!-- Treemap -->
268 - <div class="treemap-section"> 328 + <div class="treemap-wrapper" bind:this={treemapContainer}>
269 - <div
270 - bind:this={treemapContainer}
271 - class="treemap-container"
272 - style="height: {treemapHeight}px;"
273 - >
274 {#if loading} 329 {#if loading}
275 - <div class="loading-state"> 330 + <div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
276 - <div class="loader"></div>
277 - <p>Cargando distribución...</p>
278 - </div>
279 {:else if treemapNodes.length > 0} 331 {:else if treemapNodes.length > 0}
280 - <svg width={treemapWidth} height={treemapHeight} class="block"> 332 + <svg width={treemapWidth} height={treemapHeight}>
333 + <!-- Group labels (subareas) -->
334 + {#each treemapGroups as group}
335 + {@const gw = group.x1 - group.x0}
336 + {@const color = getSubareaColor(group.data.name)}
337 + {@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
338 + {#if gw > 50}
339 + <rect
340 + x={group.x0 + 3}
341 + y={group.y0 + 2}
342 + width={Math.min(label.length * 6.5 + 10, gw - 6)}
343 + height="14"
344 + rx="3"
345 + fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
346 + />
347 + <text
348 + x={group.x0 + 8}
349 + y={group.y0 + 13}
350 + fill={isDark ? '#F5F0E8' : '#1a1a1a'}
351 + font-size="9"
352 + font-family="'Qanelas', var(--font-sans)"
353 + font-weight="600"
354 + >
355 + {label}
356 + </text>
357 + {/if}
358 + {/each}
359 +
360 + <!-- Entity nodes -->
281 {#each treemapNodes as node, i} 361 {#each treemapNodes as node, i}
282 - {@const width = node.x1 - node.x0} 362 + {@const w = node.x1 - node.x0}
283 - {@const height = node.y1 - node.y0} 363 + {@const h = node.y1 - node.y0}
284 - {@const isHovered = hoveredNode === node}
285 {@const pct = (node.value / totalDevengado) * 100} 364 {@const pct = (node.value / totalDevengado) * 100}
286 - {@const area = width * height} 365 + {@const isHovered = hoveredNode === node}
287 - {@const scale = Math.sqrt(area) / 12} 366 + {@const area = Math.sqrt(w * h)}
288 - {@const pctSize = Math.min(22, Math.max(10, scale * 1.6))} 367 + {@const pctSize = Math.min(20, Math.max(9, area / 8))}
289 - {@const nameSize = Math.min(14, Math.max(8, scale * 1.0))} 368 + {@const nameSize = Math.min(12, Math.max(7, area / 12))}
290 - {@const valueSize = Math.min(12, Math.max(7, scale * 0.8))} 369 + {@const color = getSubareaColor(node.data.subarea)}
291 - {@const textColor = getTextColor(i, treemapNodes.length)}
292 370
293 <g 371 <g
294 - class="treemap-node"
295 transform="translate({node.x0}, {node.y0})" 372 transform="translate({node.x0}, {node.y0})"
296 onmouseenter={() => hoveredNode = node} 373 onmouseenter={() => hoveredNode = node}
297 onmouseleave={() => hoveredNode = null} 374 onmouseleave={() => hoveredNode = null}
375 + style="cursor: pointer;"
376 + onclick={() => goto(`/objeto/${codigo}?entidad=${node.data.id}`)}
298 > 377 >
299 <rect 378 <rect
300 - width={width} 379 + width={w} height={h}
301 - height={height} 380 + fill={color}
302 - fill={getEntityColor(i, treemapNodes.length, isHovered ? 1 : 0.85)} 381 + opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
303 - stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'} 382 + stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
304 - stroke-width={isHovered ? 2 : 0} 383 + stroke-width={isHovered ? 2 : 0.5}
305 - rx="3" 384 + rx="2"
306 /> 385 />
307 - 386 + {#if w > 35 && h > 22}
308 - {#if width > 40 && height > 25} 387 + {@const textColor = getTextColorForBg(color)}
309 - <foreignObject x="4" y="4" width={width - 8} height={height - 8}> 388 + {@const textColorSec = getTextColorSecondary(color)}
310 - <div class="node-content" style="color: {textColor};"> 389 + <foreignObject x="4" y="3" width={w - 8} height={h - 6}>
311 - <div class="node-pct" style="font-size: {pctSize}px;"> 390 + <div class="node-text">
312 - {pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}% 391 + <span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
313 - </div> 392 + {#if h > 40 && w > 55}
314 - {#if height > 45 && width > 60} 393 + <span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
315 - <div class="node-name" style="font-size: {nameSize}px;">
316 - {width > 120 ? node.data.name : node.data.name.slice(0, Math.floor(width / 7)) + (node.data.name.length > Math.floor(width / 7) ? '…' : '')}
317 - </div>
318 - {/if}
319 - {#if height > 65 && width > 70}
320 - <div class="node-value" style="font-size: {valueSize}px;">
321 - {formatMoneyCompact(node.value)}
322 - </div>
323 {/if} 394 {/if}
324 </div> 395 </div>
325 </foreignObject> 396 </foreignObject>
...@@ -328,423 +399,378 @@ ...@@ -328,423 +399,378 @@
328 {/each} 399 {/each}
329 </svg> 400 </svg>
330 401
331 - <!-- Tooltip -->
332 {#if hoveredNode} 402 {#if hoveredNode}
333 {@const pct = (hoveredNode.value / totalDevengado) * 100} 403 {@const pct = (hoveredNode.value / totalDevengado) * 100}
334 - <div 404 + <div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
335 - class="treemap-tooltip" 405 + <span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
336 - style=" 406 + <span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
337 - left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 280)}px; 407 + <span class="tooltip-name">{hoveredNode.data.name}</span>
338 - top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px; 408 + <span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
339 - "
340 - >
341 - <div class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</div>
342 - <div class="tooltip-name">{hoveredNode.data.name}</div>
343 - <div class="tooltip-value">{formatMoney(hoveredNode.value)}</div>
344 - <div class="tooltip-pct">{pct.toFixed(1)}% del total · {formatPerCapita(hoveredNode.value)}</div>
345 </div> 409 </div>
346 {/if} 410 {/if}
347 {:else} 411 {:else}
348 - <div class="empty-state"> 412 + <div class="loading">No hay datos para {selectedYear}</div>
349 - <p>No hay datos de entidades para este objeto en {selectedYear}</p>
350 - </div>
351 {/if} 413 {/if}
352 </div> 414 </div>
353 - </div>
354 415
355 - <!-- Ranking list -->
356 - {#if !loading && entidadesData.length > 0}
357 - <div class="ranking-section">
358 - <h2 class="ranking-title">Ranking de entidades</h2>
359 - <div class="ranking-list">
360 - {#each entidadesData.slice(0, 20) as entity, i}
361 - {@const pct = (entity.devengado / totalDevengado) * 100}
362 - <div class="ranking-item">
363 - <span class="ranking-pos">#{i + 1}</span>
364 - <div class="ranking-bar-container">
365 - <div class="ranking-bar" style="width: {pct}%; background: {getEntityColor(i, entidadesData.length)};"></div>
366 - <div class="ranking-info">
367 - <span class="ranking-name">{entity.desc_entidad || `Entidad ${entity.entidad}`}</span>
368 - <span class="ranking-value">{formatMoney(entity.devengado)} <span class="ranking-pct">({pct.toFixed(1)}%)</span></span>
369 - </div>
370 </div> 416 </div>
371 - </div>
372 - {/each}
373 - {#if entidadesData.length > 20}
374 - <p class="ranking-more">+ {entidadesData.length - 20} entidades más</p>
375 - {/if}
376 - </div>
377 - </div>
378 - {/if}
379 </div> 417 </div>
380 418
381 <style> 419 <style>
382 - .page-container { 420 + .page {
383 min-height: 100vh; 421 min-height: 100vh;
384 - background: var(--theme-fondo); 422 + width: 100%;
385 - padding: 1.5rem; 423 + max-width: 100%;
424 + overflow-x: hidden;
425 + background: var(--theme-body);
426 + color: var(--theme-titulo);
427 + font-family: var(--font-sans), -apple-system, sans-serif;
428 + opacity: 0;
429 + transition: opacity 0.4s;
430 + }
431 + .page.mounted { opacity: 1; }
432 +
433 + :global(html:not(.dark)) .page { background: #f5f5f7; }
434 +
435 + .layout {
386 max-width: 1400px; 436 max-width: 1400px;
387 margin: 0 auto; 437 margin: 0 auto;
438 + padding: 80px 2rem 2rem;
439 + height: 100vh;
440 + display: flex;
441 + flex-direction: column;
442 + overflow: hidden;
388 } 443 }
389 444
390 /* Header */ 445 /* Header */
391 - .page-header { 446 + .header { flex-shrink: 0; margin-bottom: 1rem; }
392 - margin-bottom: 1.5rem;
393 - }
394 447
395 - .back-btn { 448 + .header-top {
396 - display: inline-flex; 449 + display: flex;
450 + justify-content: space-between;
397 align-items: center; 451 align-items: center;
398 - gap: 0.5rem; 452 + margin-bottom: 0.375rem;
399 - padding: 0.5rem 1rem;
400 - background: var(--theme-surface);
401 - border: 1px solid var(--theme-borde);
402 - border-radius: 8px;
403 - color: var(--theme-texto);
404 - font-size: 0.875rem;
405 - cursor: pointer;
406 - transition: all 0.15s ease;
407 - margin-bottom: 1rem;
408 - }
409 - .back-btn:hover {
410 - background: var(--theme-fill);
411 - border-color: var(--theme-accent);
412 - color: var(--theme-accent);
413 } 453 }
414 454
415 - .header-content { 455 + .pills { display: flex; gap: 0.375rem; }
416 - margin-bottom: 1rem;
417 - }
418 456
419 - .header-badge { 457 + .pill {
420 - display: inline-block; 458 + font-family: 'DM Mono', monospace;
421 - padding: 0.25rem 0.75rem; 459 + font-size: 0.625rem;
422 - background: var(--theme-accent); 460 + padding: 0.125rem 0.5rem;
423 - color: white;
424 - font-size: 0.75rem;
425 - font-weight: 600;
426 - text-transform: uppercase;
427 - letter-spacing: 0.5px;
428 border-radius: 4px; 461 border-radius: 4px;
429 - margin-bottom: 0.5rem; 462 + letter-spacing: 0.03em;
430 } 463 }
464 + .pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
465 + .pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
431 466
432 - .header-title { 467 + .header-actions { display: flex; align-items: center; gap: 0.75rem; }
433 - font-size: 1.5rem; 468 +
434 - font-weight: 600; 469 + .copy-link-btn {
435 - color: var(--theme-titulo); 470 + display: flex;
436 - margin: 0 0 0.25rem 0; 471 + align-items: center;
437 - line-height: 1.3; 472 + gap: 0.375rem;
473 + font-size: 0.75rem;
474 + color: var(--theme-texto);
475 + opacity: 0.6;
476 + background: none;
477 + border: none;
478 + padding: 0;
479 + cursor: pointer;
480 + transition: opacity 0.15s;
438 } 481 }
482 + .copy-link-btn:hover { opacity: 1; }
439 483
440 - .header-code { 484 + .back-link {
441 - font-family: var(--font-mono); 485 + display: inline-flex;
442 - opacity: 0.7; 486 + align-items: center;
487 + gap: 0.25rem;
488 + font-family: 'DM Mono', monospace;
489 + font-size: 0.6875rem;
490 + color: var(--theme-accent);
491 + text-decoration: none;
492 + transition: opacity 0.15s;
443 } 493 }
444 - .header-separator { 494 + .back-link:hover { opacity: 0.7; }
445 - opacity: 0.4; 495 +
446 - margin: 0 0.25rem; 496 + .title {
497 + font-family: 'DM Serif Display', serif;
498 + font-size: 2rem;
499 + font-weight: 400;
500 + margin: 0 0 0.25rem;
501 + line-height: 1.15;
447 } 502 }
448 503
449 - .header-subtitle { 504 + .subtitle {
450 - font-size: 1rem; 505 + font-size: 0.8125rem;
451 color: var(--theme-texto); 506 color: var(--theme-texto);
452 - opacity: 0.7; 507 + opacity: 0.6;
453 - margin: 0; 508 + margin: 0 0 0.75rem;
454 } 509 }
455 510
456 - .year-selector { 511 + .meta-row {
457 display: flex; 512 display: flex;
458 align-items: center; 513 align-items: center;
459 - gap: 0.5rem; 514 + justify-content: space-between;
460 - } 515 + gap: 1rem;
461 - .year-selector label {
462 - font-size: 0.875rem;
463 - color: var(--theme-texto);
464 } 516 }
465 - .year-selector select { 517 +
466 - padding: 0.5rem 1rem; 518 + .year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
467 - background: var(--theme-surface); 519 + .year-selector::-webkit-scrollbar { display: none; }
520 +
521 + .year-btn {
522 + font-family: 'DM Mono', monospace;
523 + font-size: 0.6875rem;
524 + padding: 0.25rem 0.625rem;
468 border: 1px solid var(--theme-borde); 525 border: 1px solid var(--theme-borde);
469 border-radius: 6px; 526 border-radius: 6px;
470 - color: var(--theme-titulo); 527 + background: transparent;
471 - font-size: 0.875rem; 528 + color: var(--theme-texto);
472 cursor: pointer; 529 cursor: pointer;
530 + transition: all 0.15s;
531 + }
532 + .year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
533 + .year-btn.active {
534 + background: var(--theme-accent);
535 + border-color: var(--theme-accent);
536 + color: white;
537 + font-weight: 600;
473 } 538 }
474 539
475 - /* Stats bar */ 540 + .stats {
476 - .stats-bar {
477 display: flex; 541 display: flex;
478 - gap: 2rem; 542 + align-items: center;
479 - padding: 1rem 1.5rem; 543 + gap: 0.375rem;
480 - background: var(--theme-surface); 544 + font-size: 0.75rem;
481 - border: 1px solid var(--theme-borde); 545 + color: var(--theme-texto);
482 - border-radius: 12px; 546 + opacity: 0.7;
483 - margin-bottom: 1.5rem;
484 } 547 }
548 + .stats strong { color: var(--theme-titulo); font-weight: 600; }
549 + .stat-sep { opacity: 0.3; }
485 550
486 - .stat { 551 + /* Slider */
552 + .slider-row {
487 display: flex; 553 display: flex;
488 - flex-direction: column; 554 + justify-content: space-between;
555 + align-items: center;
556 + margin-top: 0.5rem;
489 } 557 }
490 - .stat-value { 558 +
491 - font-size: 1.25rem; 559 + .slider-label {
492 - font-weight: 600; 560 + font-family: 'DM Mono', monospace;
561 + font-size: 0.6875rem;
493 color: var(--theme-titulo); 562 color: var(--theme-titulo);
494 - font-variant-numeric: tabular-nums; 563 + font-weight: 600;
495 } 564 }
496 - .stat-label { 565 +
497 - font-size: 0.75rem; 566 + .slider-info {
567 + font-size: 0.6875rem;
498 color: var(--theme-texto); 568 color: var(--theme-texto);
499 - opacity: 0.7; 569 + opacity: 0.6;
500 - text-transform: uppercase;
501 - letter-spacing: 0.5px;
502 } 570 }
503 571
504 - /* Treemap */ 572 + .range-slider {
505 - .treemap-section { 573 + position: relative;
506 - margin-bottom: 2rem; 574 + height: 20px;
575 + margin-bottom: 0.5rem;
507 } 576 }
508 577
509 - .treemap-container { 578 + .range-slider input[type="range"] {
579 + position: absolute;
580 + width: 100%;
581 + height: 4px;
582 + top: 8px;
583 + pointer-events: none;
584 + -webkit-appearance: none;
585 + appearance: none;
586 + background: transparent;
587 + }
588 +
589 + .range-slider input[type="range"]:first-child {
590 + background: var(--theme-borde);
591 + border-radius: 2px;
592 + }
593 +
594 + .range-slider input[type="range"]::-webkit-slider-thumb {
595 + -webkit-appearance: none;
596 + appearance: none;
597 + width: 14px;
598 + height: 14px;
599 + border-radius: 50%;
600 + background: var(--theme-accent);
601 + border: 2px solid var(--theme-surface);
602 + cursor: pointer;
603 + pointer-events: all;
604 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
605 + }
606 +
607 + .range-slider input[type="range"]::-moz-range-thumb {
608 + width: 14px;
609 + height: 14px;
610 + border-radius: 50%;
611 + background: var(--theme-accent);
612 + border: 2px solid var(--theme-surface);
613 + cursor: pointer;
614 + pointer-events: all;
615 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
616 + }
617 +
618 + /* Treemap */
619 + .treemap-wrapper {
620 + flex: 1;
621 + min-height: 200px;
510 position: relative; 622 position: relative;
511 background: var(--theme-surface); 623 background: var(--theme-surface);
512 - border: 1px solid var(--theme-borde);
513 border-radius: 12px; 624 border-radius: 12px;
514 overflow: hidden; 625 overflow: hidden;
515 } 626 }
516 627
517 - .loading-state, 628 + .treemap-wrapper svg { display: block; width: 100%; height: 100%; }
518 - .empty-state { 629 +
630 + .loading {
519 position: absolute; 631 position: absolute;
520 inset: 0; 632 inset: 0;
521 display: flex; 633 display: flex;
522 - flex-direction: column;
523 align-items: center; 634 align-items: center;
524 justify-content: center; 635 justify-content: center;
525 color: var(--theme-texto); 636 color: var(--theme-texto);
526 - opacity: 0.7; 637 + opacity: 0.5;
527 - } 638 + font-size: 0.875rem;
528 -
529 - .loader {
530 - width: 40px;
531 - height: 40px;
532 - border: 3px solid var(--theme-borde);
533 - border-top-color: var(--theme-accent);
534 - border-radius: 50%;
535 - animation: spin 1s linear infinite;
536 - margin-bottom: 1rem;
537 - }
538 -
539 - @keyframes spin {
540 - to { transform: rotate(360deg); }
541 - }
542 -
543 - :global(.treemap-node) {
544 - transition: opacity 0.15s ease;
545 - }
546 - :global(.treemap-node rect) {
547 - transition: fill 0.15s ease;
548 } 639 }
549 640
550 - .node-content { 641 + .node-text {
551 display: flex; 642 display: flex;
552 flex-direction: column; 643 flex-direction: column;
553 - gap: 0;
554 overflow: hidden; 644 overflow: hidden;
555 - font-family: var(--font-sans);
556 } 645 }
557 646
558 - .node-pct { 647 + .node-pct { font-weight: 700; line-height: 1; }
559 - font-weight: 600; 648 + .node-name { line-height: 1.2; margin-top: 1px; }
560 - line-height: 1;
561 - opacity: 0.9;
562 - }
563 -
564 - .node-name {
565 - line-height: 1.2;
566 - margin-top: 2px;
567 - opacity: 0.85;
568 - }
569 -
570 - .node-value {
571 - font-variant-numeric: tabular-nums;
572 - opacity: 0.7;
573 - margin-top: 2px;
574 - }
575 649
576 /* Tooltip */ 650 /* Tooltip */
577 - .treemap-tooltip { 651 + .tooltip {
578 position: absolute; 652 position: absolute;
579 pointer-events: none; 653 pointer-events: none;
580 - padding: 0.75rem 1rem; 654 + padding: 0.5rem 0.75rem;
581 - border-radius: 10px; 655 + border-radius: 8px;
582 z-index: 10; 656 z-index: 10;
583 - max-width: 280px; 657 + max-width: 250px;
584 - background: rgba(255, 255, 255, 0.85); 658 + background: rgba(30, 30, 30, 0.9);
585 backdrop-filter: blur(12px); 659 backdrop-filter: blur(12px);
586 - -webkit-backdrop-filter: blur(12px);
587 - border: 1px solid rgba(255, 255, 255, 0.3);
588 - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
589 - color: #1a1a1a;
590 - font-family: var(--font-sans);
591 - }
592 -
593 - :global(html.dark) .treemap-tooltip {
594 - background: rgba(30, 30, 30, 0.85);
595 border: 1px solid rgba(255, 255, 255, 0.1); 660 border: 1px solid rgba(255, 255, 255, 0.1);
596 - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 661 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
597 color: #f5f5f5; 662 color: #f5f5f5;
663 + display: flex;
664 + flex-direction: column;
665 + gap: 2px;
598 } 666 }
599 667
600 - .tooltip-rank { 668 + :global(html:not(.dark)) .tooltip {
601 - font-size: 0.7rem; 669 + background: rgba(255, 255, 255, 0.9);
602 - font-weight: 600; 670 + border-color: rgba(0, 0, 0, 0.1);
603 - text-transform: uppercase; 671 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
604 - letter-spacing: 0.5px; 672 + color: #1a1a1a;
605 - opacity: 0.6;
606 - margin-bottom: 0.25rem;
607 } 673 }
608 674
609 - .tooltip-name { 675 + .tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
610 - font-size: 0.9rem; 676 + .tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
611 - font-weight: 500; 677 + .tooltip-name { font-size: 0.8125rem; font-weight: 600; }
612 - line-height: 1.3; 678 + .tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
613 - }
614 679
615 - .tooltip-value { 680 + /* Leyenda */
616 - font-size: 0.875rem; 681 + .legend {
617 - font-weight: 600; 682 + display: flex;
618 - margin-top: 0.5rem; 683 + flex-wrap: wrap;
619 - font-variant-numeric: tabular-nums; 684 + gap: 0.5rem 1rem;
685 + padding-top: 0.5rem;
686 + flex-shrink: 0;
620 } 687 }
621 688
622 - .tooltip-pct { 689 + .legend-item {
623 - font-size: 0.75rem; 690 + display: flex;
691 + align-items: center;
692 + gap: 0.25rem;
693 + font-size: 0.625rem;
694 + color: var(--theme-texto);
624 opacity: 0.7; 695 opacity: 0.7;
625 - font-variant-numeric: tabular-nums;
626 } 696 }
627 697
628 - /* Ranking list */ 698 + .legend-dot {
629 - .ranking-section { 699 + width: 8px;
630 - background: var(--theme-surface); 700 + height: 8px;
631 - border: 1px solid var(--theme-borde); 701 + border-radius: 2px;
632 - border-radius: 12px; 702 + flex-shrink: 0;
633 - padding: 1.5rem;
634 } 703 }
635 704
636 - .ranking-title { 705 + /* Responsive */
637 - font-size: 1rem; 706 + @media (max-width: 900px) {
638 - font-weight: 600; 707 + .layout {
639 - color: var(--theme-titulo); 708 + padding: 70px 1rem 1rem;
640 - margin: 0 0 1rem 0; 709 + height: auto;
641 - } 710 + min-height: 100vh;
642 - 711 + overflow: visible;
643 - .ranking-list {
644 - display: flex;
645 - flex-direction: column;
646 - gap: 0.75rem;
647 } 712 }
648 713
649 - .ranking-item { 714 + .treemap-wrapper {
650 - display: grid; 715 + min-height: 350px;
651 - grid-template-columns: 2.5rem 1fr; 716 + height: 50vh;
652 - gap: 0.75rem; 717 + flex: none;
653 - align-items: center;
654 } 718 }
655 -
656 - .ranking-pos {
657 - font-size: 0.875rem;
658 - font-weight: 600;
659 - color: var(--theme-texto);
660 - opacity: 0.5;
661 - font-variant-numeric: tabular-nums;
662 } 719 }
663 720
664 - .ranking-bar-container { 721 + @media (max-width: 640px) {
665 - position: relative; 722 + .layout { padding: 60px 0.75rem 1rem; }
666 - height: 32px; 723 + .title { font-size: 1.375rem; }
667 - background: var(--theme-fill); 724 + .subtitle { font-size: 0.75rem; }
668 - border-radius: 4px; 725 + .meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
669 - overflow: hidden; 726 + .header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
670 - }
671 -
672 - .ranking-bar {
673 - position: absolute;
674 - left: 0;
675 - top: 0;
676 - height: 100%;
677 - border-radius: 4px;
678 - transition: width 0.3s ease;
679 - }
680 727
681 - .ranking-info { 728 + .year-selector {
682 - position: absolute; 729 + flex-wrap: wrap;
683 - left: 0.75rem; 730 + gap: 0.25rem;
684 - right: 0.75rem;
685 - top: 50%;
686 - transform: translateY(-50%);
687 - display: flex;
688 - justify-content: space-between;
689 - align-items: center;
690 - pointer-events: none;
691 } 731 }
692 732
693 - .ranking-name { 733 + .year-btn {
694 - font-size: 0.8125rem; 734 + font-size: 0.625rem;
695 - font-weight: 500; 735 + padding: 0.2rem 0.5rem;
696 - color: var(--theme-titulo);
697 - white-space: nowrap;
698 - overflow: hidden;
699 - text-overflow: ellipsis;
700 - max-width: 60%;
701 } 736 }
702 737
703 - .ranking-value { 738 + .slider-row {
704 - font-size: 0.8125rem; 739 + flex-direction: column;
705 - font-weight: 500; 740 + align-items: flex-start;
706 - color: var(--theme-titulo); 741 + gap: 0.125rem;
707 - font-variant-numeric: tabular-nums;
708 } 742 }
709 743
710 - .ranking-pct { 744 + .stats { font-size: 0.6875rem; }
711 - opacity: 0.6;
712 - font-weight: 400;
713 - }
714 745
715 - .ranking-more { 746 + .treemap-wrapper {
716 - font-size: 0.8125rem; 747 + min-height: 300px;
717 - color: var(--theme-texto); 748 + height: 45vh;
718 - opacity: 0.6;
719 - text-align: center;
720 - margin: 0.5rem 0 0 0;
721 } 749 }
722 750
723 - /* Responsive */ 751 + .tooltip {
724 - @media (max-width: 640px) { 752 + max-width: 200px;
725 - .page-container { 753 + font-size: 0.75rem;
726 - padding: 1rem;
727 } 754 }
728 755
729 - .header-title { 756 + .header-actions {
730 - font-size: 1.25rem; 757 + width: 100%;
758 + justify-content: space-between;
731 } 759 }
732 -
733 - .stats-bar {
734 - flex-wrap: wrap;
735 - gap: 1rem;
736 } 760 }
737 761
738 - .stat-value { 762 + @media (max-width: 380px) {
739 - font-size: 1.1rem; 763 + .layout { padding: 55px 0.5rem 0.75rem; }
740 - } 764 + .title { font-size: 1.125rem; }
741 765
742 - .ranking-item { 766 + .treemap-wrapper {
743 - grid-template-columns: 2rem 1fr; 767 + min-height: 250px;
768 + height: 40vh;
744 } 769 }
745 770
746 - .ranking-name { 771 + .year-btn {
747 - max-width: 50%; 772 + font-size: 0.5625rem;
773 + padding: 0.15rem 0.375rem;
748 } 774 }
749 } 775 }
750 </style> 776 </style>
......
1 +import { redirect } from '@sveltejs/kit';
2 +
3 +export function load({ params }) {
4 + throw redirect(301, `/objeto/${params.codigo}?entidad=${params.entidad}`);
5 +}
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
25 let clasificadorSeleccionado = $state('total'); // total, objeto, finfun, acteco 25 let clasificadorSeleccionado = $state('total'); // total, objeto, finfun, acteco
26 let subPartidaSearch = $state(''); 26 let subPartidaSearch = $state('');
27 let subPartidaSeleccionada = $state(null); 27 let subPartidaSeleccionada = $state(null);
28 + let mapLinkCopied = $state(false);
28 29
29 const CLASIFICADORES_MAPA = [ 30 const CLASIFICADORES_MAPA = [
30 { id: 'total', label: 'Gasto total', disponible: true }, 31 { id: 'total', label: 'Gasto total', disponible: true },
...@@ -128,7 +129,7 @@ ...@@ -128,7 +129,7 @@
128 const res = await fetch(`${apiEndpoint}?${params}`); 129 const res = await fetch(`${apiEndpoint}?${params}`);
129 const data = await res.json(); 130 const data = await res.json();
130 const mapped = (data || []) 131 const mapped = (data || [])
131 - .filter(d => d.ubigeo !== '0.0.0') 132 + .filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo))
132 .map(d => ({ 133 .map(d => ({
133 codigo: d.ubigeo, 134 codigo: d.ubigeo,
134 desc: d.desc_ubigeo, 135 desc: d.desc_ubigeo,
...@@ -393,11 +394,19 @@ ...@@ -393,11 +394,19 @@
393 goto(url.toString(), { replaceState: true, noScroll: true }); 394 goto(url.toString(), { replaceState: true, noScroll: true });
394 } 395 }
395 396
396 - // Resolver nombre de partida por código desde Typesense 397 + // Resolver nombre de partida por código desde la API
397 async function resolverNombre(clas, codigo) { 398 async function resolverNombre(clas, codigo) {
399 + try {
400 + if (clas === 'objeto') {
401 + const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=clasificador`);
402 + if (res.ok) {
403 + const data = await res.json();
404 + if (data?.desc_objeto) return data.desc_objeto;
405 + }
406 + }
407 + // Fallback: buscar en Typesense
398 const cfg = CLAS_SEARCH_MAP[clas]; 408 const cfg = CLAS_SEARCH_MAP[clas];
399 if (!cfg) return codigo; 409 if (!cfg) return codigo;
400 - try {
401 const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ }); 410 const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ });
402 const res = await fetch(`/api/search?${params}`); 411 const res = await fetch(`/api/search?${params}`);
403 if (!res.ok) return codigo; 412 if (!res.ok) return codigo;
...@@ -477,10 +486,34 @@ ...@@ -477,10 +486,34 @@
477 <div class="mapa-fullscreen"> 486 <div class="mapa-fullscreen">
478 <div class="mapa-top-controls"> 487 <div class="mapa-top-controls">
479 <nav class="breadcrumb"> 488 <nav class="breadcrumb">
480 - <button class="back-btn" onclick={() => { history.back(); }}>←</button> 489 + {#if clasSeleccionado && clasificadorSeleccionado === 'objeto'}
481 - <a href="/">Inicio</a> 490 + <a href="/objeto/{clasSeleccionado.codigo}" class="back-link-mapa">
482 - <span class="sep">/</span> 491 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
483 - <span>Ubicación geográfica</span> 492 + Volver a {clasSeleccionado.nombre}
493 + </a>
494 + {:else if clasSeleccionado && clasificadorSeleccionado === 'finfun'}
495 + <a href="/finfun/{clasSeleccionado.codigo}" class="back-link-mapa">
496 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
497 + Volver a {clasSeleccionado.nombre}
498 + </a>
499 + {:else}
500 + <span class="mapa-location-label">Ubicación geográfica</span>
501 + {/if}
502 + <button class="copy-link-btn-mapa" onclick={() => {
503 + navigator.clipboard.writeText(window.location.href);
504 + mapLinkCopied = true;
505 + setTimeout(() => mapLinkCopied = false, 2000);
506 + }}>
507 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
508 + {#if mapLinkCopied}
509 + <path d="M20 6L9 17l-5-5"/>
510 + {:else}
511 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
512 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
513 + {/if}
514 + </svg>
515 + {mapLinkCopied ? 'Copiado' : 'Compartir'}
516 + </button>
484 </nav> 517 </nav>
485 <!-- Fila 1: Dropdowns inline --> 518 <!-- Fila 1: Dropdowns inline -->
486 <div class="titulo-dropdowns"> 519 <div class="titulo-dropdowns">
...@@ -527,7 +560,6 @@ ...@@ -527,7 +560,6 @@
527 <div class="objeto-search-mapa"> 560 <div class="objeto-search-mapa">
528 {#if clasSeleccionado} 561 {#if clasSeleccionado}
529 <div class="objeto-selected-pill"> 562 <div class="objeto-selected-pill">
530 - <span class="objeto-pill-code">{clasSeleccionado.codigo}</span>
531 <span class="objeto-pill-name">{clasSeleccionado.nombre}</span> 563 <span class="objeto-pill-name">{clasSeleccionado.nombre}</span>
532 <button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button> 564 <button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button>
533 </div> 565 </div>
...@@ -769,12 +801,41 @@ ...@@ -769,12 +801,41 @@
769 flex-shrink: 0; 801 flex-shrink: 0;
770 } 802 }
771 803
772 - .breadcrumb { font-size: 0.7rem; color: var(--theme-texto); opacity: 0.5; display: flex; align-items: center; gap: 0.3rem; } 804 + .breadcrumb { font-size: 0.7rem; color: var(--theme-texto); display: flex; align-items: center; gap: 0.75rem; }
773 - .back-btn { background: none; border: none; cursor: pointer; color: var(--theme-texto); font-size: 0.85rem; padding: 0; opacity: 0.6; transition: opacity 0.15s; } 805 +
774 - .back-btn:hover { opacity: 1; } 806 + .back-link-mapa {
775 - .breadcrumb a { color: var(--theme-texto); text-decoration: none; } 807 + display: inline-flex;
776 - .breadcrumb a:hover { text-decoration: underline; opacity: 1; } 808 + align-items: center;
777 - .breadcrumb .sep { margin: 0 0.3rem; } 809 + gap: 0.25rem;
810 + font-family: 'DM Mono', monospace;
811 + font-size: 0.6875rem;
812 + color: var(--theme-accent);
813 + text-decoration: none;
814 + transition: opacity 0.15s;
815 + }
816 + .back-link-mapa:hover { opacity: 0.7; }
817 +
818 + .mapa-location-label {
819 + font-size: 0.6875rem;
820 + color: var(--theme-texto);
821 + opacity: 0.5;
822 + }
823 +
824 + .copy-link-btn-mapa {
825 + display: flex;
826 + align-items: center;
827 + gap: 0.25rem;
828 + font-size: 0.6875rem;
829 + color: var(--theme-texto);
830 + opacity: 0.5;
831 + background: none;
832 + border: none;
833 + padding: 0;
834 + cursor: pointer;
835 + transition: opacity 0.15s;
836 + margin-left: auto;
837 + }
838 + .copy-link-btn-mapa:hover { opacity: 1; }
778 839
779 .titulo-dropdowns { 840 .titulo-dropdowns {
780 display: flex; 841 display: flex;
......