Showing
16 changed files
with
1485 additions
and
972 deletions
| ... | @@ -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 | +} |
src/routes/api/objeto-data/+server.js
0 → 100644
| 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 | +} |
src/routes/api/objeto-treemap/+server.js
0 → 100644
| 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> | ... | ... |
| ... | @@ -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; | ... | ... |
-
Please register or login to post a comment