Rafael Lopez

objetos

node_modules
API_ENDPOINTS.md
# Output
.output
......
......@@ -36,9 +36,16 @@
let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50));
// Máximo del eje Y (con guard para data vacía o valores undefined)
let maxPerCapita = $derived(
data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 1) : 1
);
let maxPerCapita = $derived.by(() => {
if (data.length === 0) return 1;
const vals = data.map(d => typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0);
const max = Math.max(...vals);
const min = Math.min(...vals.filter(v => v > 0));
// Si max es 0, usar 1 como fallback
if (max <= 0) return 1;
// Agregar 10% de margen arriba para que la barra más alta no toque el techo
return max * 1.1;
});
// Escalas D3
let xScale = $derived(
......@@ -92,7 +99,8 @@
<!-- Barras -->
<div class="bars" onmouseleave={() => hoveredYear = null} role="group">
{#each data as d}
{@const barPct = (yScale(d.perCapita) / chartHeight) * 100}
{@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0}
{@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0}
<div
class="bar-container"
onmouseenter={() => hoveredYear = d.año}
......@@ -102,7 +110,7 @@
<div
class="bar"
class:hovered={hoveredYear === d.año}
style="height: {barPct}%"
style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
></div>
</div>
{/each}
......
......@@ -109,7 +109,7 @@
onkeydown={handleSearchKeydown}
onfocus={() => searchFocused = true}
onblur={handleSearchBlur}
placeholder="Buscar objeto..."
placeholder="Buscar otro gasto..."
/>
{#if searchVal}
<button class="search-clear" onclick={handleClear}>
......
......@@ -108,7 +108,19 @@
codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`;
}
return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion };
// Contexto extra para programas
let contexto = '';
if (!isClass) {
const ents = meta.entidades || [];
const entNombre = ents.length === 1 ? ents[0].entidad_desc_entidad : ents.length > 1 ? `${ents.length} entidades` : '';
const prog = meta.programa_desc || '';
if (entNombre && prog) contexto = `${entNombre} · Programa: ${prog}`;
else if (entNombre) contexto = entNombre;
else if (prog) contexto = `Programa: ${prog}`;
console.log('[SEARCH] programa contexto:', { isClass, tipo, nombre, entNombre, prog, contexto, metaKeys: Object.keys(meta) });
}
return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion, contexto };
});
selectedIdx = -1;
} catch {
......@@ -184,7 +196,6 @@
}
function goToResult(item) {
closeModal();
const routes = {
entidad: '/entidad/',
objeto_gasto: '/objeto/',
......@@ -196,7 +207,12 @@
programa: '/proyecto/'
};
const base = routes[item.tipo];
if (base) goto(`${base}${item.codigo}`);
if (base) {
const url = `${base}${item.codigo}`;
console.log('[SEARCH] goToResult:', item.tipo, item.codigo, '→', url);
open = false;
window.location.href = url;
}
}
function handleBackdropClick(e) {
......@@ -319,6 +335,9 @@
{typeConfig[item.tipo]?.label || item.tipo}
</span>
<span class="result-name">{@html item.highlight}</span>
{#if item.contexto}
<span class="result-contexto">{item.contexto}</span>
{/if}
</div>
<span class="result-code">{item.codigo}</span>
</button>
......@@ -646,6 +665,15 @@
text-overflow: ellipsis;
}
.result-contexto {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-desc {
font-size: 0.8125rem;
color: var(--theme-texto);
......
......@@ -1379,9 +1379,16 @@
</div>
<div class="result-meta">
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
{#if meta.entidad_desc_entidad}
{#if meta.entidades?.length === 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidad_desc_entidad}</span>
<span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span>
{:else if meta.entidades?.length > 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidades.length} entidades</span>
{/if}
{#if meta.programa_desc}
<span class="result-sep">·</span>
<span class="result-entity">{meta.programa_desc}</span>
{/if}
</div>
</div>
......@@ -1443,10 +1450,20 @@
<span class="result-year">{formatYears(meta.gestion)}</span>
<span class="result-sep">·</span>
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
{#if meta.entidad_desc_entidad}
{#if meta.entidades?.length === 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span>
{:else if meta.entidades?.length > 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidades.length} entidades</span>
{:else if meta.entidad_desc_entidad}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidad_desc_entidad}</span>
{/if}
{#if meta.programa_desc}
<span class="result-sep">·</span>
<span class="result-entity">Programa: {meta.programa_desc}</span>
{/if}
{/if}
{#if isEntidadClass || isClassResult}
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
......
const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'clasificador';
const entidad = url.searchParams.get('entidad') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'entidades-lista') {
apiUrl = `${API_BASE}/${codigo}/entidades/map`;
} else if (tipo === 'entidad' && entidad) {
apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
} else if (tipo === 'entidades-año' && gestion) {
apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
export async function GET() {
try {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error();
const data = await res.json();
// Mapear al formato que espera el treemap
const mapped = data.map(d => ({
entidad: d.entidad,
desc_entidad: d.desc_entidad,
sigla_entidad: d.sigla_entidad || ''
}));
return new Response(JSON.stringify(mapped), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto';
// Cache en memoria del servidor
let cachedClasificador = null;
let cachedEstados = null; // Map: objeto → estados[]
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutos
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
// 1. Cargar clasificador
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
// 2. Cargar todos los objetos en paralelo (lotes de 80)
const estados = new Map();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.objeto}`);
if (!res.ok) return null;
const data = await res.json();
return { objeto: item.objeto, estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.objeto, r.estados));
}
cachedEstados = estados;
cachedTimestamp = now;
}
export async function GET({ url }) {
const gestion = parseInt(url.searchParams.get('gestion') || '2025');
const entidad = url.searchParams.get('entidad') || '0';
try {
if (entidad === '0') {
// Todo el estado — usar cache
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const estados = cachedEstados.get(item.objeto);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel,
objeto: item.objeto,
desc_objeto: item.desc_objeto,
parent: getParent(item.objeto, item.nivel),
devengado: yearData.total
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
// Entidad específica — cargar por entidad (no cacheable eficientemente)
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.objeto}/entidades/${entidad}`);
if (!res.ok) return null;
const data = await res.json();
const yearData = data.find(d => d.gestion === gestion);
if (!yearData || !yearData.monto) return null;
return {
gestion,
nivel: item.nivel,
objeto: item.objeto,
desc_objeto: item.desc_objeto,
parent: getParent(item.objeto, item.nivel),
devengado: yearData.monto
};
} catch { return null; }
}));
results.push(...batchResults.filter(Boolean));
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
function getParent(objeto, nivel) {
if (nivel === 'grupo') return null;
if (nivel === 'subgrupo') return objeto.charAt(0) + '0000';
if (nivel === 'partida') return objeto.substring(0, 2) + '000';
if (nivel === 'subpartida') return objeto.substring(0, 3) + '00';
return null;
}
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
......@@ -97,7 +96,8 @@
// Selector de nivel para vista aplanada
let treemapViewLevel = $state('partida'); // 'jerarquico' | 'grupo' | 'subgrupo' | 'partida' | 'subpartida'
let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias)
let showConsolidado = $state(true);
let clasLinkCopied = $state(false);
const NIVEL_OPTIONS = [
{ value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' },
{ value: 'grupo', label: 'Grupos', desc: '8-9 categorías' },
......@@ -193,42 +193,22 @@
// Cargar datos del treemap desde Supabase
async function loadTreemapData() {
// Limpiar estado antes de cargar nuevos datos
treemapNodes = [];
try {
// Query a Supabase con filtros en el servidor para mejor rendimiento
// selectedEntity = null significa "Todo el Estado" (entidad = 0)
const entidadFiltro = selectedEntity?.entidad ?? 0;
const res = await fetch(`/api/objeto-treemap?gestion=${selectedYear}&entidad=${entidadFiltro}`);
const data = await res.json();
let query = supabase
.schema('ppto')
.from('treemap_objeto')
.select('gestion, nivel, objeto, desc_objeto, parent, devengado')
.eq('gestion', selectedYear)
.eq('entidad', entidadFiltro)
.gt('devengado', 0);
const { data, error: dbError } = await query;
if (dbError) {
console.error('Error fetching treemap data:', dbError.message, dbError.code, dbError.details);
if (data.error) {
console.error('Error fetching treemap data:', data.error);
return;
}
// Procesar datos: convertir tipos y filtrar subpartidas duplicadas
let filtered = data
.map(d => ({
gestion: d.gestion,
nivel: d.nivel,
objeto: d.objeto,
desc_objeto: d.desc_objeto,
parent: d.parent,
devengado: d.devengado || 0
}))
.filter(d => d.devengado > 0)
.filter(d => !(d.nivel === 'subpartida' && d.desc_objeto === null));
// Si es vista consolidada, excluir grupo 7 (Transferencias)
if (showConsolidado) {
filtered = filtered.filter(d => !String(d.objeto).startsWith('7'));
}
......@@ -246,28 +226,18 @@
if (!year) return;
try {
const { data, error: dbError } = await supabase
.schema('ppto')
.from('entidades_treemap')
.select('entidad, desc_entidad, sigla_entidad')
.eq('gestion', year)
.order('desc_entidad');
if (dbError) {
console.error('Error loading entities:', dbError.message);
return;
}
const res = await fetch('/api/objeto-treemap-entidades');
const data = await res.json();
entities = data || [];
console.log('Entities loaded for year', year, ':', entities.length);
if (Array.isArray(data)) {
entities = data.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
console.log('Entities loaded:', entities.length);
}
// Si hay entidad seleccionada, verificar que existe en el nuevo año
if (selectedEntity) {
const existsInYear = entities.some(e => e.entidad === selectedEntity.entidad);
if (!existsInYear) {
// La entidad no existe en este año, volver a Todo el Estado
selectedEntity = null;
console.log('Entity not available for year', year, '- reset to Todo el Estado');
}
}
} catch (err) {
......@@ -549,10 +519,12 @@
// Navegar a vista de distribución por entidades
function navigateToEntityDistribution(node) {
// Obtener el código del objeto (puede ser grupo, subgrupo, partida o subpartida)
const codigo = node.id || node.data?.objeto;
let codigo = node.id || node.data?.objeto;
// Si es un grupo (id como "grupo_1"), convertir a código real
if (typeof codigo === 'string' && codigo.startsWith('grupo_')) {
codigo = codigo.replace('grupo_', '') + '0000';
}
if (codigo) {
// Navegar a la ruta de distribución por entidades
goto(`/objeto/${codigo}/entidades?gestion=${selectedYear}`);
}
}
......@@ -797,56 +769,34 @@
return;
}
// Si no hay cache, cargar datos
// Cargar clasificador de objetos
const { data, error } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.order('objeto')
.range(0, 9999);
if (!error && data) {
allItems = data;
// Extraer grupos únicos
grupos = data
.filter(item => item.nivel === 'grupo')
.reduce((acc, item) => {
if (!acc.find(g => g.objeto === item.objeto)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => a.objeto.localeCompare(b.objeto));
// Cargar clasificador de objetos desde API
try {
const res = await fetch('/api/objeto-clasificador');
const data = await res.json();
if (grupos.length > 0) {
selectedGrupo = grupos[0];
}
}
if (Array.isArray(data)) {
allItems = data;
// Cargar años disponibles desde Supabase
try {
const { data: yearsData, error: yearsError } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('gestion')
.eq('entidad', 0)
.eq('nivel', 'grupo')
.gt('devengado', 0);
if (!yearsError && yearsData && yearsData.length > 0) {
const uniqueYears = [...new Set(yearsData.map(d => d.gestion))].sort((a, b) => b - a);
availableYears = uniqueYears;
selectedYear = availableYears[0]; // Año más reciente por defecto
} else {
// Fallback: años de 2005 a 2025
availableYears = Array.from({ length: 21 }, (_, i) => 2025 - i);
selectedYear = 2025;
grupos = data
.filter(item => item.nivel === 'grupo')
.sort((a, b) => String(a.objeto).localeCompare(String(b.objeto)));
if (grupos.length > 0) {
selectedGrupo = grupos[0];
}
// Años desde gestiones del primer grupo
if (data[0]?.gestiones) {
availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a);
selectedYear = availableYears[0];
} else {
availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
selectedYear = 2025;
}
}
} catch (err) {
// Fallback si falla la carga
availableYears = Array.from({ length: 21 }, (_, i) => 2025 - i);
console.error('Error loading clasificador:', err);
availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
selectedYear = 2025;
}
......@@ -1061,16 +1011,11 @@
const entidadFiltro = config.entity?.entidad ?? 0;
try {
const { data, error: dbError } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('gestion, nivel, objeto, desc_objeto, parent, devengado')
.eq('gestion', config.year)
.eq('entidad', entidadFiltro)
.gt('devengado', 0);
if (dbError) {
console.error(`Error loading compare ${side} data:`, dbError);
const res = await fetch(`/api/objeto-treemap?gestion=${config.year}&entidad=${entidadFiltro}`);
const data = await res.json();
if (data.error) {
console.error(`Error loading compare ${side} data:`, data.error);
return;
}
......@@ -1542,9 +1487,6 @@
</nav>
<div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
<p class="text-sm uppercase tracking-widest mb-2 font-semibold" style="font-family: var(--font-sans); color: var(--theme-accent);">
Clasificador de Objeto del Gasto
</p>
<div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}">
<h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
¿En qué se gasta?
......@@ -1563,6 +1505,29 @@
<span class="hidden sm:inline">Cómo leer</span>
</button>
{/if}
<div style="margin-left: auto; display: flex; align-items: center; gap: 0.75rem;">
<button class="share-btn-clas" onclick={() => {
navigator.clipboard.writeText(window.location.href);
clasLinkCopied = true;
setTimeout(() => clasLinkCopied = false, 2000);
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if clasLinkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
{clasLinkCopied ? 'Copiado' : 'Compartir'}
</button>
<a href="/" class="share-btn-clas" style="text-decoration: none;">
<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>
Volver
</a>
</div>
</div>
{#if viewMode === 'mapa'}
<div class="title-selectors">
......@@ -2150,42 +2115,6 @@
</button>
<div class="sidebar-content">
<!-- Selector de Modo (Lista, Mapa, Comparar) -->
<div class="sidebar-section">
<label class="sidebar-label">Explorar</label>
<div class="sidebar-modes">
<button
class="mode-btn"
class:active={viewMode === 'lista'}
onclick={() => { setMode('lista'); mapaSidebarOpen = false; }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="mode-btn"
class:active={viewMode === 'mapa'}
onclick={() => { setMode('mapa'); mapaSidebarOpen = false; }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
Mapa
</button>
<button
class="mode-btn"
class:active={viewMode === 'comparar'}
onclick={() => { setMode('comparar'); initCompareMode(); mapaSidebarOpen = false; }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
Comparar
</button>
</div>
</div>
<!-- Navegación drill-down (solo en modo jerárquico con depth > 1) -->
{#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 1}
......@@ -2227,24 +2156,6 @@
</div>
</div>
<!-- Toggle Consolidado/Agregado -->
<div class="sidebar-section">
<label class="sidebar-label">Tipo</label>
<button
class="sidebar-toggle"
onclick={() => showConsolidado = !showConsolidado}
>
<span class="toggle-switch" class:active={showConsolidado}>
<span class="toggle-knob"></span>
</span>
<span class="toggle-label">
{showConsolidado ? 'Consolidado' : 'Agregado'}
</span>
</button>
<p class="toggle-hint">
{showConsolidado ? 'Sin transferencias entre entidades' : 'Incluye transferencias (grupo 7)'}
</p>
</div>
</div>
</aside>
......@@ -2293,10 +2204,10 @@
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onclick={() => hasChildren && drillDown(node)}
onclick={() => hasChildren ? drillDown(node) : goto(`/objeto/${node.id}`)}
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: {hasChildren ? 'pointer' : 'default'};"
style="cursor: pointer;"
>
<rect
width={width}
......@@ -2603,31 +2514,6 @@
</button>
<div class="sidebar-content">
<!-- Selector de Modo -->
<div class="sidebar-section">
<label class="sidebar-label">Explorar</label>
<div class="sidebar-modes">
<button class="mode-btn" class:active={viewMode === 'lista'} onclick={() => { setMode('lista'); mapaSidebarOpen = false; }}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button class="mode-btn" class:active={viewMode === 'mapa'} onclick={() => { setMode('mapa'); mapaSidebarOpen = false; }}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
Mapa
</button>
<button class="mode-btn active" onclick={() => mapaSidebarOpen = false}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
Comparar
</button>
</div>
</div>
<!-- Selector de Nivel -->
<div class="sidebar-section">
<label class="sidebar-label">Nivel</label>
......@@ -2652,18 +2538,6 @@
</div>
</div>
<!-- Toggle Consolidado/Agregado -->
<div class="sidebar-section">
<label class="sidebar-label">Tipo</label>
<button class="sidebar-toggle" onclick={() => showConsolidado = !showConsolidado}>
<span class="toggle-switch" class:active={showConsolidado}>
<span class="toggle-knob"></span>
</span>
<span class="toggle-label">{showConsolidado ? 'Consolidado' : 'Agregado'}</span>
</button>
<p class="toggle-hint">{showConsolidado ? 'Sin transferencias' : 'Con transferencias'}</p>
</div>
<!-- Botón intercambiar -->
<div class="sidebar-section">
<button
......@@ -4201,6 +4075,22 @@
box-shadow: 0 4px 24px rgba(28,28,26,0.12), inset 0 0 0 1px rgba(28,28,26,0.06) !important;
}
.share-btn-clas {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
margin-left: auto;
}
.share-btn-clas:hover { opacity: 1; }
/* Toggle button para Consolidado/Agregado */
.toggle-btn {
border: 1px solid var(--theme-borde);
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código objeto
function getGrupoCode(objeto) {
return objeto.charAt(0) + '0000';
}
function getSubgrupoCode(objeto) {
return objeto.substring(0, 2) + '000';
}
function getPartidaCode(objeto) {
return objeto.substring(0, 3) + '00';
}
function getNivel(objeto) {
if (objeto.endsWith('0000')) return 'grupo';
if (objeto.endsWith('000')) return 'subgrupo';
if (objeto.endsWith('00')) return 'partida';
return 'subpartida';
}
const API_BASE = 'http://136.112.29.74/api/objeto';
export async function load({ params }) {
console.time('[SERVER] Total load objeto');
const { codigo } = params;
console.time('[SERVER] Query clas_objetos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', codigo);
console.timeEnd('[SERVER] Query clas_objetos');
// Cargar clasificador desde la API
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Objeto no encontrado');
const objeto = await res.json();
if (dbError || !data || data.length === 0) {
throw error(404, 'Objeto no encontrado');
}
// Tomar el primer resultado
const objeto = data[0];
const nivel = objeto.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
// Derivar padres e hijos del código
const nivel = objeto.nivel;
let padresCodes = [];
console.time('[SERVER] Query padres');
// Buscar padres según nivel
if (nivel === 'subpartida') {
// Padres: grupo, subgrupo, partida
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const partidaCode = getPartidaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode, partidaCode])
.order('objeto');
if (padresData) padres = padresData;
padresCodes = [
codigo.charAt(0) + '0000',
codigo.substring(0, 2) + '000',
codigo.substring(0, 3) + '00'
];
} else if (nivel === 'partida') {
// Padres: grupo, subgrupo
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode])
.order('objeto');
if (padresData) padres = padresData;
padresCodes = [
codigo.charAt(0) + '0000',
codigo.substring(0, 2) + '000'
];
} else if (nivel === 'subgrupo') {
// Padre: grupo
const grupoCode = getGrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', grupoCode);
if (padresData) padres = padresData;
}
// grupo no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
if (nivel === 'grupo') {
// Hijos: subgrupos que empiecen con el mismo dígito
const prefix = codigo.charAt(0);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
} else if (nivel === 'subgrupo') {
// Hijos: partidas que empiecen con los mismos 2 dígitos
const prefix = codigo.substring(0, 2);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
} else if (nivel === 'partida') {
// Hijos: subpartidas que empiecen con los mismos 3 dígitos
const prefix = codigo.substring(0, 3);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subpartida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
padresCodes = [codigo.charAt(0) + '0000'];
}
// subpartida no tiene hijos
console.timeEnd('[SERVER] Query hijos');
// Cargar padres e hijos en paralelo desde la API
const [padresResults, hijosResult] = await Promise.all([
Promise.all(padresCodes.map(async (c) => {
try {
const r = await fetch(`${API_BASE}/${c}`);
if (r.ok) return await r.json();
} catch {}
return null;
})),
(async () => {
// Hijos: buscar en el clasificador
try {
const r = await fetch(`${API_BASE}/clasificador`);
if (!r.ok) return [];
const all = await r.json();
if (nivel === 'grupo') {
return all.filter(o => o.nivel === 'subgrupo' && o.objeto.charAt(0) === codigo.charAt(0) && o.objeto !== codigo);
} else if (nivel === 'subgrupo') {
return all.filter(o => o.nivel === 'partida' && o.objeto.substring(0, 2) === codigo.substring(0, 2));
} else if (nivel === 'partida') {
return all.filter(o => o.nivel === 'subpartida' && o.objeto.substring(0, 3) === codigo.substring(0, 3));
}
return [];
} catch { return []; }
})()
]);
const padres = padresResults.filter(Boolean).sort((a, b) => a.objeto.localeCompare(b.objeto));
const hijos = hijosResult.sort((a, b) => a.objeto.localeCompare(b.objeto));
console.timeEnd('[SERVER] Total load objeto');
return {
objeto,
padres,
hijos
hijos,
nEntidades: 0
};
}
......
......@@ -4,7 +4,6 @@
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { supabase } from '$lib/supabase';
import Spinner from '$lib/components/ui/Spinner.svelte';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
......@@ -18,38 +17,62 @@
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(true);
let loading = $state(false);
let hoveredYear = $state(null);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
let vista = $state('agregado'); // 'agregado' | 'comparar'
let linkCopied = $state(false);
// Parsear descripciones
function parseDescripciones(str) {
if (!str) return [];
try {
const parsed = JSON.parse(str);
return parsed.sort((a, b) => {
const yA = a.rangos?.match(/\d{4}/g) || [];
const yB = b.rangos?.match(/\d{4}/g) || [];
return Math.max(...yB.map(Number), 0) - Math.max(...yA.map(Number), 0);
});
} catch { return []; }
// Parsear descripciones - la API devuelve array directo con { gestion, descripcion }
function getMaxYear(str) {
if (!str) return 0;
const years = str.match(/\d{4}/g);
return years ? Math.max(...years.map(Number)) : 0;
}
let descripciones = $derived(parseDescripciones(objetoData.descripciones));
let variaciones = $derived(descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion })));
let descripciones = $derived.by(() => {
const descs = objetoData.descripciones;
if (!descs) return [];
let arr;
if (typeof descs === 'string') {
try { arr = JSON.parse(descs); } catch { return []; }
} else {
arr = Array.isArray(descs) ? descs : [];
}
// Ordenar por año más reciente primero
return [...arr].sort((a, b) => getMaxYear(b.gestion || b.rangos) - getMaxYear(a.gestion || a.rangos));
});
let variaciones = $derived(descripciones.map(d => ({ rango: d.gestion || d.rangos || '', texto: d.descripcion })));
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(data.nEntidades);
// Datos
let datosAnuales = $state([]);
let datosHistorico = $state(null);
let topEntidadesPorAño = $state({});
let nEntidades = $derived(entidades.length || data.nEntidades || 0);
// Datos — inicializar desde estados del server
function initEstados(obj) {
const estadosRaw = obj?.estados || [];
const seen = new Set();
const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
const hist = estados.find(d => d.gestion === 0) || null;
const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
const topPorAño = {};
anuales.forEach(d => {
topPorAño[d.gestion] = [
{ nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
{ nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
{ nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
].filter(e => e.nombre);
});
return { hist, anuales, topPorAño };
}
let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(objetoData);
let datosAnuales = $state(initAnuales);
let datosHistorico = $state(initHist);
let topEntidadesPorAño = $state(initTop);
const POBLACION = 12000000;
let objetoCodigo = $derived(objetoData.objeto);
......@@ -57,8 +80,8 @@
let gastoPerCapita = $derived(
datosAnuales.map(d => {
const monto = selectedEntity ? (d.monto ?? d.devengado) : d.total;
const perCapita = d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0);
const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
return {
año: d.gestion,
monto,
......@@ -70,35 +93,66 @@
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
let totalPorcentaje = $derived(datosHistorico?.prop || 0);
let ranking = $derived(datosHistorico?.ranking || 0);
let nPares = $derived(datosHistorico?.n_pares || 0);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
let ranking = $derived(datosHistorico?.ranking ?? 0);
let nPares = $derived(datosHistorico?.n_pares ?? 0);
let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
let currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
// Datos del año hovereado desde datosAnuales (tiene todos los campos de la API)
let currentAnual = $derived(hoveredYear ? datosAnuales.find(d => d.gestion === hoveredYear) : null);
// KPI 1: Monto
let displayMonto = $derived.by(() => {
if (selectedEntity) {
return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
}
return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
});
let displayMontoLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `gastados en ${currentAnual.gestion}` : `gastados ${primerAño}-${ultimoAño}`;
}
return currentAnual ? `gasto total del Estado en ${currentAnual.gestion}` : `gasto total ${primerAño}-${ultimoAño}`;
});
// KPI 2: Per cápita
let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
// KPI 3: Proporción (prop)
let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
let displayPorcentajeLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `del total de esta partida en ${currentAnual.gestion}` : 'del total de esta partida';
}
return currentAnual ? `del gasto en ${currentAnual.gestion}` : 'del gasto total';
});
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
let nivelKey = $derived(objetoData.nivel?.toLowerCase());
let nivelPlural = $derived({ grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' }[nivelKey] || 'objetos');
let nivelLabel = $derived({ grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }[nivelKey] || objetoData.nivel);
let displayRankingLabel = $derived(currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`);
let displayRankingLabel = $derived.by(() => {
if (selectedEntity) {
return currentAnual ? `entre las que más gastan en ${currentAnual.gestion}` : 'entre las que más gastan';
}
return currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`;
});
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
{ nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
{ nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
{ nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
{ nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
{ nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
......@@ -114,9 +168,9 @@
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(displayMonto); });
$effect(() => { twPerCapita.set(displayPerCapita); });
$effect(() => { twPorcentaje.set(displayPorcentaje); });
$effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
$effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
$effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
......@@ -133,38 +187,45 @@
}
async function loadData() {
if (!selectedEntity) return;
loading = true;
if (selectedEntity) {
const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion >= 2016);
topEntidadesPorAño = {};
}
} else {
const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
.eq('objeto', objetoCodigo).order('gestion');
if (data) {
try {
const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
const raw = await res.json();
console.log('[loadData] entidad:', selectedEntity.entidad, 'raw rows:', raw?.length);
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion >= 2016);
topEntidadesPorAño = {};
datosAnuales.forEach(d => {
topEntidadesPorAño[d.gestion] = [
{ nombre: d.top1_entidad, porcentaje: d.top1_pct },
{ nombre: d.top2_entidad, porcentaje: d.top2_pct },
{ nombre: d.top3_entidad, porcentaje: d.top3_pct },
].filter(e => e.nombre);
});
console.log('[loadData] historico:', datosHistorico ? { monto: datosHistorico.monto, per_capita: datosHistorico.per_capita, prop: datosHistorico.prop } : null);
// Forzar tweens directamente
if (datosHistorico) {
twMonto.set(datosHistorico.monto || 0);
twPerCapita.set(datosHistorico.per_capita || 0);
twPorcentaje.set(datosHistorico.prop || 0);
}
}
} catch (err) {
console.error('[loadData] Error:', err);
}
loading = false;
}
async function loadEntidades() {
const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc');
if (data) entidades = data;
try {
const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidades-lista`);
const data = await res.json();
if (Array.isArray(data)) {
entidades = data
.filter(d => d.gestion === 0)
.map(d => ({ entidad: d.entidad, entidad_desc: d.desc_entidad }))
.sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
}
} catch {
entidades = [];
}
}
// ══════════════════════════════════════════════════════════════
......@@ -206,11 +267,15 @@
});
async function loadDatosEntidad(entidad) {
const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', entidad.entidad).order('gestion');
if (data) {
return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
}
try {
const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
const raw = await res.json();
if (Array.isArray(raw)) {
const seen = new Set();
const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
}
} catch {}
return { anual: [], historico: null };
}
......@@ -246,6 +311,7 @@
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
async function selectEntidadB(entidad) {
......@@ -257,6 +323,7 @@
loadingComparacion = true;
datosCompararB = await loadDatosEntidad(entidad);
loadingComparacion = false;
updateCompararUrl();
}
function handleEntityScroll(event, type) {
......@@ -315,8 +382,13 @@
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
// Valor según métrica seleccionada
function valComparar(d) {
return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
}
let maxComparacion = $derived.by(() => {
const all = [...datosGraficoA.map(d => d.per_capita || 0), ...datosGraficoB.map(d => d.per_capita || 0)];
const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
return Math.max(...all, 1) * 1.1;
});
......@@ -376,12 +448,28 @@
twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
});
// Actualizar URL para vista comparar
function updateCompararUrl() {
if (!mounted) return;
const url = new URL($page.url);
url.searchParams.set('vista', 'comparar');
url.searchParams.delete('entidad');
if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
else url.searchParams.delete('entidadA');
if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
else url.searchParams.delete('entidadB');
goto(url.toString(), { replaceState: true, noScroll: true });
}
// Watch vista changes
let previousVista = 'agregado';
$effect(() => {
if (mounted && vista !== previousVista) {
previousVista = vista;
if (vista === 'comparar') initComparacion();
if (vista === 'comparar') {
initComparacion();
updateCompararUrl();
}
}
});
......@@ -397,31 +485,101 @@
onMount(() => {
mounted = true;
initIndex();
// Read URL params
const urlVista = $page.url.searchParams.get('vista');
if (urlVista === 'comparar') vista = 'comparar';
loadEntidades().then(async () => {
// Read URL params after entidades are loaded
const urlVista = $page.url.searchParams.get('vista');
const urlEntidad = $page.url.searchParams.get('entidad');
const urlEntidadA = $page.url.searchParams.get('entidadA');
const urlEntidadB = $page.url.searchParams.get('entidadB');
if (urlVista === 'comparar') {
vista = 'comparar';
// Precargar entidades de comparación desde URL
if (urlEntidadA) {
const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
if (entA) {
entidadCompararA = entA;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entA);
}
}
if (urlEntidadB) {
const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
if (entB) {
entidadCompararB = entB;
datosCompararB = await loadDatosEntidad(entB);
}
}
loadingComparacion = false;
if (!urlEntidadA && !urlEntidadB) initComparacion();
} else if (urlEntidad && entidades.length > 0) {
const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
if (ent) selectedEntity = ent;
}
});
});
// Recargar entidades cuando cambia el objeto
// Recargar cuando cambia el objeto (navegación entre partidas)
let prevCodigo = null;
$effect(() => {
const code = objetoCodigo;
if (code !== prevCodigo) {
prevCodigo = code;
selectedEntity = null;
if (prevCodigo !== null && code !== prevCodigo) {
// Reiniciar comparación
entidadCompararA = null;
entidadCompararB = null;
datosCompararA = { anual: [], historico: null };
datosCompararB = { anual: [], historico: null };
loadEntidades();
// Cargar datos del estado desde objetoData.estados
const { hist, anuales, topPorAño } = initEstados(objetoData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
// Recargar entidades y mantener la seleccionada si existe en la nueva partida
const prevEntity = selectedEntity;
loadEntidades().then(() => {
if (prevEntity) {
const match = entidades.find(e => e.entidad === prevEntity.entidad);
if (match) {
selectedEntity = match;
loadData();
} else {
selectedEntity = null;
}
}
});
}
prevCodigo = code;
});
// Recargar datos cuando cambia el objeto o la entidad
// Recargar cuando cambia la entidad seleccionada
let prevEntity = null;
$effect(() => {
const _code = objetoCodigo;
const _entity = selectedEntity;
loadData();
const ent = selectedEntity;
if (ent !== prevEntity) {
prevEntity = ent;
if (ent) {
loadData();
} else if (prevCodigo) {
const { hist, anuales, topPorAño } = initEstados(objetoData);
datosHistorico = hist;
datosAnuales = anuales;
topEntidadesPorAño = topPorAño;
loading = false;
}
// Actualizar URL
if (mounted) {
const url = new URL($page.url);
if (ent) {
url.searchParams.set('entidad', ent.entidad);
} else {
url.searchParams.delete('entidad');
}
goto(url.toString(), { replaceState: true, noScroll: true });
}
}
});
</script>
......@@ -431,14 +589,6 @@
</svelte:head>
<div class="page" class:mounted>
<nav class="page-nav">
<a href="/clasificadores/objeto-gasto" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
{#if vista === 'agregado'}
<div class="dashboard-layout">
......@@ -458,7 +608,6 @@
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<p class="context-hint">Buscar otro objeto de gasto</p>
<ObjetoSearch />
</div>
......@@ -515,13 +664,6 @@
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
</div>
<a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
</svg>
<span class="explore-title">Comparar partidas</span>
<span class="explore-desc">Todas las partidas entre dos momentos o entidades</span>
</a>
<a href="/objeto/{objetoData.objeto}/entidades" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
......@@ -529,7 +671,7 @@
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/ubicacion" class="explore-card explore-card-disabled">
<a href="/ubicacion?clas=objeto&codigo={objetoData.objeto}" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<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"/>
</svg>
......@@ -543,6 +685,13 @@
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
</svg>
<span class="explore-title">Comparar partidas</span>
<span class="explore-desc">Todas las partidas entre dos momentos o entidades</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
......@@ -577,17 +726,44 @@
<!-- Definición -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
{#if variaciones.length <= 1}
<p class="context-hint">Definición estable en todo el período</p>
{:else}
<p class="context-hint">El significado de este gasto ha cambiado {variaciones.length} veces en el período</p>
{/if}
<button class="def-link" onclick={() => showDefinicionesModal = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
{#if variaciones.length === 1}
Ver definición oficial
{#if variaciones.length <= 1}
Ver definición
{:else}
Ver {variaciones.length} variaciones en la definición
Ver variaciones
{/if}
</button>
</div>
<div class="context-separator"></div>
<!-- Compartir -->
<div class="context-section">
<h3 class="context-subtitle">Compartir</h3>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
</button>
</div>
</div>
</aside>
......@@ -606,22 +782,40 @@
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
{#if selectedEntity}
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<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>
Todo el Estado
</button>
{/if}
</div>
<div class="chart-header">
<span class="chart-title">
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<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>
</button>
<span class="chart-period">· {displayPeriodo}</span>
</span>
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Ver monto real' : 'Ver per cápita'}
</button>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else if gastoPerCapita.length === 0 && selectedEntity}
<div class="chart-no-data">
<p>Esta entidad no tiene registros en {objetoData.desc_objeto?.toLowerCase()} desde 2016</p>
<button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
<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>
Volver a Todo el Estado
</button>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
......@@ -651,16 +845,25 @@
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<h3 class="ranking-title">Concentración del gasto</h3>
<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>
<div class="ranking-header">
<h3 class="ranking-title">Concentración del gasto</h3>
<div class="ranking-summary">
<span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
<span class="ranking-summary-text">de {objetoData.desc_objeto?.toLowerCase()} lo gastan estas 3 entidades{hoveredYear ? ` en ${hoveredYear}` : ''}</span>
</div>
</div>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<div class="ranking-card">
<button class="ranking-card ranking-card-link" onclick={() => {
const match = entidades.find(e => e.entidad === ent.entidad);
if (match) selectedEntity = match;
}}>
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
</div>
<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>
</button>
{/each}
</div>
</div>
......@@ -783,7 +986,28 @@
<div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
{:else}
<div class="chart-header">
<span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodoComparar}</span></span>
<span class="chart-title">
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<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>
</button>
<span class="chart-period">· {displayPeriodoComparar}</span>
</span>
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
</div>
<div class="chart-comparar-wrapper">
......@@ -798,8 +1022,8 @@
{#each añosComparacion as año, i}
<div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
<div class="bars-pair">
<div class="bar-comparar" style="height: {((datosGraficoA[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorA}"></div>
<div class="bar-comparar" style="height: {((datosGraficoB[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorB}"></div>
<div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
<div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
</div>
</div>
{/each}
......@@ -910,8 +1134,8 @@
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 30px 2rem;
height: calc(100vh - 4rem);
padding: 80px 2rem 2.5rem;
height: 100vh;
overflow: hidden;
}
......@@ -1089,6 +1313,7 @@
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
......@@ -1199,6 +1424,53 @@
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
.chart-no-data {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
font-size: 0.8125rem;
text-align: center;
}
.chart-no-data p {
margin: 0;
}
.volver-agregado {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-accent);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.volver-agregado:hover { opacity: 0.7; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.copy-link-btn svg { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
......@@ -1297,18 +1569,23 @@
.metrica-toggle {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
border: 1px solid rgba(201, 167, 81, 0.2);
padding: 0.2rem 0.5rem;
font-size: 0.6875rem;
color: var(--theme-texto);
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
padding: 0.125rem 0.375rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.metrica-toggle:hover {
background: rgba(201, 167, 81, 0.2);
background: rgba(201, 167, 81, 0.12);
border-color: rgba(201, 167, 81, 0.3);
color: var(--theme-accent);
}
.chart-loading {
......@@ -1381,10 +1658,62 @@
.ranking-card {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: rgba(128, 128, 128, 0.06);
background: transparent;
text-align: left;
}
.ranking-card-link {
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.ranking-card-link:hover {
background: rgba(201, 167, 81, 0.05);
}
.ranking-card-link:hover .ranking-name {
color: var(--theme-accent);
border-bottom-style: solid;
}
.ranking-arrow {
opacity: 0;
color: var(--theme-accent);
transition: opacity 0.15s, transform 0.15s;
flex-shrink: 0;
}
.ranking-card-link:hover .ranking-arrow {
opacity: 1;
transform: translateX(2px);
}
.ranking-header {
margin-bottom: 0.5rem;
}
.ranking-summary {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin-top: 0.125rem;
}
.ranking-summary-pct {
font-family: 'DM Mono', monospace;
font-size: 1.25rem;
font-weight: 700;
color: var(--theme-titulo);
}
.ranking-summary-text {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.ranking-position {
......@@ -1396,19 +1725,17 @@
}
.ranking-name {
flex: 1;
font-size: 0.8125rem;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
opacity: 0.7;
}
/* ═══════════════════════════════════════════════════════════════
......@@ -1707,11 +2034,12 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 2rem 1.5rem;
padding: 80px 2rem 2.5rem;
width: 100%;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 60px);
height: 100vh;
overflow: hidden;
}
.chart-card-comparar {
......
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import * as d3 from 'd3';
// Params y query
let codigo = $derived($page.params.codigo);
let gestion = $derived($page.url.searchParams.get('gestion') || new Date().getFullYear().toString());
// Estado
let loading = $state(true);
let objetoInfo = $state(null); // Info del objeto (desc_objeto, nivel)
let mounted = $state(false);
let objetoInfo = $state(null);
let entidadesData = $state([]);
let availableYears = $state([]);
let selectedYear = $state(null);
let linkCopied = $state(false);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
let entidadClasificador = $state({}); // Map entidad → { desc_subarea, sigla_subarea, desc_area, ... }
let subareas = $state([]); // Lista única de subareas con color
let sliderMin = $state(0);
let sliderMax = $state(100);
// Filtrar entidades por rango de % acumulado
let filteredEntidades = $derived.by(() => {
if (sliderMin === 0 && sliderMax === 100) return entidadesData;
let acum = 0;
return entidadesData.filter(d => {
const pctBefore = (acum / totalDevengado) * 100;
acum += d.monto;
const pctAfter = (acum / totalDevengado) * 100;
return pctAfter > sliderMin && pctBefore < sliderMax;
});
});
let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
let filteredCount = $derived(filteredEntidades.length);
// Treemap
let treemapContainer = $state(null);
let treemapWidth = $state(0);
let treemapHeight = $state(500);
let treemapHeight = $state(0);
let treemapNodes = $state([]);
let treemapRoot = $state(null);
let hoveredNode = $state(null);
let totalDevengado = $state(0);
let treemapGroups = $state([]); // Nodos padre (subareas)
// Población para per cápita
const POBLACION_BOLIVIA = 12006031;
// Años disponibles desde gestiones del objeto
let availableYears = $derived.by(() => {
if (!objetoInfo?.gestiones) return [];
return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a);
});
// Formatters
function formatMoney(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
return `Bs ${value.toFixed(0)}`;
function formatMonto(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
return v?.toLocaleString('es-BO') || '0';
}
function formatMoneyCompact(value) {
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`;
return value.toFixed(0);
function formatCompact(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
return v?.toFixed(0) || '0';
}
function formatPerCapita(value) {
const perCapita = value / POBLACION_BOLIVIA;
return `Bs ${perCapita.toFixed(0)}/hab`;
}
// Colores por subarea — misma paleta que clasificador objeto-gasto
let isDark = $state(false);
const SUBAREA_COLORS = [
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
];
// Colores para entidades (gradiente basado en ranking)
function getEntityColor(index, total, opacity = 0.85) {
// Gradiente de más oscuro (top) a más claro (bottom)
const hue = 210; // Azul
const saturation = 60 + (index / total) * 20;
const lightness = 35 + (index / total) * 30;
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`;
function getSubareaColor(subarea) {
const idx = subareas.indexOf(subarea);
return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
}
function getTextColor(index, total) {
return index < total * 0.6 ? '#ffffff' : '#1a1a1a';
// Contraste de texto según luminosidad del fondo (WCAG)
function getTextColorForBg(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.95)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
// Cargar años disponibles
async function loadAvailableYears() {
const { data, error } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('gestion')
.eq('objeto', parseInt(codigo))
.gt('devengado', 0);
function getTextColorSecondary(bgColor) {
const color = d3.color(bgColor);
if (!color) return 'rgba(255,255,255,0.88)';
const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
}
if (!error && data) {
const years = [...new Set(data.map(d => d.gestion))].sort((a, b) => b - a);
availableYears = years;
// Cargar clasificador de entidades
async function loadClasificador() {
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (Array.isArray(data)) {
const map = {};
data.forEach(d => { map[d.entidad] = d; });
entidadClasificador = map;
}
} catch {}
}
// Usar año de query param o el más reciente
const yearParam = parseInt(gestion);
selectedYear = years.includes(yearParam) ? yearParam : years[0];
}
// Cargar info del objeto desde API
async function loadObjeto() {
try {
const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=clasificador`);
const data = await res.json();
if (data && !data.error) objetoInfo = data;
} catch {}
}
// Cargar datos de entidades para el objeto
async function loadEntityData() {
// Cargar entidades desde API
async function loadEntidades() {
if (!selectedYear || !codigo) return;
loading = true;
try {
// Obtener info del objeto
const { data: infoData } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('desc_objeto, nivel')
.eq('objeto', parseInt(codigo))
.eq('gestion', selectedYear)
.limit(1)
.single();
if (infoData) {
objetoInfo = infoData;
}
const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
const raw = await res.json();
if (Array.isArray(raw)) {
// Deduplicar por entidad
const map = new Map();
raw.forEach(d => {
if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
map.set(d.entidad, d);
}
});
entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
// Nombres desde map endpoint
const mapRes = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=entidades-lista`);
const mapData = await mapRes.json();
if (Array.isArray(mapData)) {
const nombres = {};
mapData.forEach(d => { if (d.desc_entidad) nombres[d.entidad] = d.desc_entidad; });
entidadesData = entidadesData.map(d => ({
...d,
desc_entidad: nombres[d.entidad] || d.desc_entidad || `Entidad ${d.entidad}`,
desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
}));
}
// Extraer subareas únicas ordenadas por monto total
const subareaMontos = {};
entidadesData.forEach(d => {
subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
});
subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
// Obtener distribución por entidades (excluyendo entidad 0=total y 99=otros)
const { data, error } = await supabase
.schema('ppto')
.from('treemap_objeto')
.select('entidad, desc_entidad, devengado')
.eq('objeto', parseInt(codigo))
.eq('gestion', selectedYear)
.neq('entidad', 0)
.neq('entidad', 99)
.gt('devengado', 0)
.order('devengado', { ascending: false });
if (error) {
console.error('Error loading entity data:', error);
return;
buildTreemap();
}
entidadesData = data || [];
totalDevengado = entidadesData.reduce((sum, d) => sum + (d.devengado || 0), 0);
buildTreemap();
} catch (err) {
console.error('Error:', err);
} finally {
loading = false;
console.error('[Entidades] Error:', err);
}
loading = false;
}
// Construir treemap
function buildTreemap() {
if (!entidadesData.length || treemapWidth <= 0) return;
if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
treemapNodes = [];
treemapGroups = [];
return;
}
const hierarchy = d3.hierarchy({
name: 'root',
children: entidadesData.map((d, i) => ({
// Agrupar por subarea
const groups = {};
filteredEntidades.forEach(d => {
const sub = d.desc_subarea || 'Otros';
if (!groups[sub]) groups[sub] = [];
groups[sub].push({
id: d.entidad,
name: d.desc_entidad || `Entidad ${d.entidad}`,
value: d.devengado,
index: i
value: d.monto,
ranking: d.ranking,
per_capita: d.per_capita,
prop: d.prop,
subarea: sub
});
});
const hierarchy = d3.hierarchy({
name: 'root',
children: Object.entries(groups).map(([subarea, children]) => ({
name: subarea,
children
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
const treemap = d3.treemap()
d3.treemap()
.size([treemapWidth, treemapHeight])
.padding(2)
.round(true);
treemap(hierarchy);
treemapRoot = hierarchy;
treemapNodes = hierarchy.children || [];
}
// Resize observer
function setupResizeObserver() {
if (!treemapContainer) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
treemapWidth = entry.contentRect.width;
buildTreemap();
}
});
.paddingOuter(3)
.paddingInner(1)
.paddingTop(18)
.round(true)(hierarchy);
observer.observe(treemapContainer);
return () => observer.disconnect();
}
// Navegación
function goBack() {
goto(`/clasificadores/objeto-gasto?modo=mapa`);
treemapNodes = hierarchy.leaves() || [];
treemapGroups = hierarchy.children || [];
}
function changeYear(year) {
selectedYear = year;
goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
loadEntidades();
}
// Efectos
$effect(() => {
if (codigo) {
loadAvailableYears();
if (treemapContainer) {
const measure = () => {
const w = treemapContainer.clientWidth;
const h = treemapContainer.clientHeight;
if (w > 0 && h > 0) {
treemapWidth = w;
treemapHeight = h;
buildTreemap();
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(treemapContainer);
return () => observer.disconnect();
}
});
$effect(() => {
if (selectedYear && codigo) {
loadEntityData();
}
});
$effect(() => {
if (treemapContainer) {
return setupResizeObserver();
}
});
onMount(async () => {
mounted = true;
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
$effect(() => {
if (entidadesData.length && treemapWidth > 0) {
buildTreemap();
await Promise.all([loadObjeto(), loadClasificador()]);
const yearParam = $page.url.searchParams.get('gestion');
if (yearParam) {
const y = parseInt(yearParam);
selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
} else {
selectedYear = availableYears[0] || 2025;
}
});
onMount(() => {
if (treemapContainer) {
treemapWidth = treemapContainer.clientWidth;
treemapHeight = treemapContainer.clientHeight;
}
await loadEntidades();
});
</script>
<svelte:head>
<title>{objetoInfo?.desc_objeto || `Objeto ${codigo}`} - Distribución por Entidades</title>
<title>{objetoInfo?.desc_objeto || `Objeto ${codigo}`} - Ranking de Entidades</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="page-header">
<button class="back-btn" onclick={goBack}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver al mapa
</button>
<div class="header-content">
<div class="header-badge">{objetoInfo?.nivel || 'objeto'}</div>
<h1 class="header-title">
<span class="header-code">{codigo}</span>
<span class="header-separator">·</span>
<span class="header-name">{objetoInfo?.desc_objeto || 'Cargando...'}</span>
</h1>
<p class="header-subtitle">¿Qué entidades gastan más en esto?</p>
</div>
<!-- Selector de año -->
<div class="year-selector">
<label>Gestión:</label>
<select bind:value={selectedYear} onchange={(e) => changeYear(parseInt(e.target.value))}>
{#each availableYears as year}
<option value={year}>{year}</option>
{/each}
</select>
</div>
</header>
<!-- Stats resumen -->
{#if !loading && entidadesData.length > 0}
<div class="stats-bar">
<div class="stat">
<span class="stat-value">{entidadesData.length}</span>
<span class="stat-label">entidades</span>
</div>
<div class="stat">
<span class="stat-value">{formatMoney(totalDevengado)}</span>
<span class="stat-label">total ejecutado</span>
<div class="page" class:mounted>
<div class="layout">
<!-- Header -->
<header class="header">
<div class="header-top">
<div class="pills">
<span class="pill pill-nivel">{objetoInfo?.nivel || 'objeto'}</span>
<span class="pill pill-codigo">{codigo}</span>
</div>
<div class="header-actions">
<button class="copy-link-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
<span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
</button>
<a href="/objeto/{codigo}" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver
</a>
</div>
</div>
<div class="stat">
<span class="stat-value">{formatPerCapita(totalDevengado)}</span>
<span class="stat-label">per cápita</span>
<h1 class="title">{objetoInfo?.desc_objeto || 'Cargando...'}</h1>
<p class="subtitle">¿Qué entidades gastan más en esto?</p>
<!-- Año + Stats -->
<div class="meta-row">
<div class="year-selector">
{#each availableYears as year}
<button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
{/each}
</div>
{#if !loading && entidadesData.length > 0}
<div class="stats">
<span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
</div>
{/if}
</div>
</div>
{/if}
<!-- Treemap -->
<div class="treemap-section">
<div
bind:this={treemapContainer}
class="treemap-container"
style="height: {treemapHeight}px;"
>
{#if loading}
<div class="loading-state">
<div class="loader"></div>
<p>Cargando distribución...</p>
<!-- Slider de rango -->
{#if !loading && entidadesData.length > 0}
<div class="slider-row">
<span class="slider-label">Rango: {sliderMin}% – {sliderMax}%</span>
<span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
</div>
<div class="range-slider">
<input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
<input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
</div>
{/if}
</header>
<!-- Treemap -->
<div class="treemap-wrapper" bind:this={treemapContainer}>
{#if loading}
<div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if treemapNodes.length > 0}
<svg width={treemapWidth} height={treemapHeight} class="block">
<svg width={treemapWidth} height={treemapHeight}>
<!-- Group labels (subareas) -->
{#each treemapGroups as group}
{@const gw = group.x1 - group.x0}
{@const color = getSubareaColor(group.data.name)}
{@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
{#if gw > 50}
<rect
x={group.x0 + 3}
y={group.y0 + 2}
width={Math.min(label.length * 6.5 + 10, gw - 6)}
height="14"
rx="3"
fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
/>
<text
x={group.x0 + 8}
y={group.y0 + 13}
fill={isDark ? '#F5F0E8' : '#1a1a1a'}
font-size="9"
font-family="'Qanelas', var(--font-sans)"
font-weight="600"
>
{label}
</text>
{/if}
{/each}
<!-- Entity nodes -->
{#each treemapNodes as node, i}
{@const width = node.x1 - node.x0}
{@const height = node.y1 - node.y0}
{@const isHovered = hoveredNode === node}
{@const w = node.x1 - node.x0}
{@const h = node.y1 - node.y0}
{@const pct = (node.value / totalDevengado) * 100}
{@const area = width * height}
{@const scale = Math.sqrt(area) / 12}
{@const pctSize = Math.min(22, Math.max(10, scale * 1.6))}
{@const nameSize = Math.min(14, Math.max(8, scale * 1.0))}
{@const valueSize = Math.min(12, Math.max(7, scale * 0.8))}
{@const textColor = getTextColor(i, treemapNodes.length)}
{@const isHovered = hoveredNode === node}
{@const area = Math.sqrt(w * h)}
{@const pctSize = Math.min(20, Math.max(9, area / 8))}
{@const nameSize = Math.min(12, Math.max(7, area / 12))}
{@const color = getSubareaColor(node.data.subarea)}
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
onclick={() => goto(`/objeto/${codigo}?entidad=${node.data.id}`)}
>
<rect
width={width}
height={height}
fill={getEntityColor(i, treemapNodes.length, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
stroke-width={isHovered ? 2 : 0}
rx="3"
width={w} height={h}
fill={color}
opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
stroke-width={isHovered ? 2 : 0.5}
rx="2"
/>
{#if width > 40 && height > 25}
<foreignObject x="4" y="4" width={width - 8} height={height - 8}>
<div class="node-content" style="color: {textColor};">
<div class="node-pct" style="font-size: {pctSize}px;">
{pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
</div>
{#if height > 45 && width > 60}
<div class="node-name" style="font-size: {nameSize}px;">
{width > 120 ? node.data.name : node.data.name.slice(0, Math.floor(width / 7)) + (node.data.name.length > Math.floor(width / 7) ? '…' : '')}
</div>
{/if}
{#if height > 65 && width > 70}
<div class="node-value" style="font-size: {valueSize}px;">
{formatMoneyCompact(node.value)}
</div>
{#if w > 35 && h > 22}
{@const textColor = getTextColorForBg(color)}
{@const textColorSec = getTextColorSecondary(color)}
<foreignObject x="4" y="3" width={w - 8} height={h - 6}>
<div class="node-text">
<span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
{#if h > 40 && w > 55}
<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>
{/if}
</div>
</foreignObject>
......@@ -328,423 +399,378 @@
{/each}
</svg>
<!-- Tooltip -->
{#if hoveredNode}
{@const pct = (hoveredNode.value / totalDevengado) * 100}
<div
class="treemap-tooltip"
style="
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 280)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
"
>
<div class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</div>
<div class="tooltip-name">{hoveredNode.data.name}</div>
<div class="tooltip-value">{formatMoney(hoveredNode.value)}</div>
<div class="tooltip-pct">{pct.toFixed(1)}% del total · {formatPerCapita(hoveredNode.value)}</div>
<div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
<span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
<span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
<span class="tooltip-name">{hoveredNode.data.name}</span>
<span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
</div>
{/if}
{:else}
<div class="empty-state">
<p>No hay datos de entidades para este objeto en {selectedYear}</p>
</div>
<div class="loading">No hay datos para {selectedYear}</div>
{/if}
</div>
</div>
<!-- Ranking list -->
{#if !loading && entidadesData.length > 0}
<div class="ranking-section">
<h2 class="ranking-title">Ranking de entidades</h2>
<div class="ranking-list">
{#each entidadesData.slice(0, 20) as entity, i}
{@const pct = (entity.devengado / totalDevengado) * 100}
<div class="ranking-item">
<span class="ranking-pos">#{i + 1}</span>
<div class="ranking-bar-container">
<div class="ranking-bar" style="width: {pct}%; background: {getEntityColor(i, entidadesData.length)};"></div>
<div class="ranking-info">
<span class="ranking-name">{entity.desc_entidad || `Entidad ${entity.entidad}`}</span>
<span class="ranking-value">{formatMoney(entity.devengado)} <span class="ranking-pct">({pct.toFixed(1)}%)</span></span>
</div>
</div>
</div>
{/each}
{#if entidadesData.length > 20}
<p class="ranking-more">+ {entidadesData.length - 20} entidades más</p>
{/if}
</div>
</div>
{/if}
</div>
</div>
<style>
.page-container {
.page {
min-height: 100vh;
background: var(--theme-fondo);
padding: 1.5rem;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 1400px;
margin: 0 auto;
padding: 80px 2rem 2rem;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.page-header {
margin-bottom: 1.5rem;
}
.header { flex-shrink: 0; margin-bottom: 1rem; }
.back-btn {
display: inline-flex;
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
color: var(--theme-texto);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
margin-bottom: 1rem;
}
.back-btn:hover {
background: var(--theme-fill);
border-color: var(--theme-accent);
color: var(--theme-accent);
margin-bottom: 0.375rem;
}
.header-content {
margin-bottom: 1rem;
}
.pills { display: flex; gap: 0.375rem; }
.header-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--theme-accent);
color: white;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
letter-spacing: 0.03em;
}
.pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
.pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
.header-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem 0;
line-height: 1.3;
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.copy-link-btn {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.copy-link-btn:hover { opacity: 1; }
.header-code {
font-family: var(--font-mono);
opacity: 0.7;
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
text-decoration: none;
transition: opacity 0.15s;
}
.header-separator {
opacity: 0.4;
margin: 0 0.25rem;
.back-link:hover { opacity: 0.7; }
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.25rem;
line-height: 1.15;
}
.header-subtitle {
font-size: 1rem;
.subtitle {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
opacity: 0.6;
margin: 0 0 0.75rem;
}
.year-selector {
.meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.year-selector label {
font-size: 0.875rem;
color: var(--theme-texto);
justify-content: space-between;
gap: 1rem;
}
.year-selector select {
padding: 0.5rem 1rem;
background: var(--theme-surface);
.year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
.year-selector::-webkit-scrollbar { display: none; }
.year-btn {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: var(--theme-titulo);
font-size: 0.875rem;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
}
.year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
.year-btn.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: white;
font-weight: 600;
}
/* Stats bar */
.stats-bar {
.stats {
display: flex;
gap: 2rem;
padding: 1rem 1.5rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 12px;
margin-bottom: 1.5rem;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.stats strong { color: var(--theme-titulo); font-weight: 600; }
.stat-sep { opacity: 0.3; }
.stat {
/* Slider */
.slider-row {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
.slider-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-titulo);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.stat-label {
font-size: 0.75rem;
.slider-info {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
}
/* Treemap */
.treemap-section {
margin-bottom: 2rem;
.range-slider {
position: relative;
height: 20px;
margin-bottom: 0.5rem;
}
.range-slider input[type="range"] {
position: absolute;
width: 100%;
height: 4px;
top: 8px;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range-slider input[type="range"]:first-child {
background: var(--theme-borde);
border-radius: 2px;
}
.range-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.treemap-container {
.range-slider input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--theme-accent);
border: 2px solid var(--theme-surface);
cursor: pointer;
pointer-events: all;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Treemap */
.treemap-wrapper {
flex: 1;
min-height: 200px;
position: relative;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 12px;
overflow: hidden;
}
.loading-state,
.empty-state {
.treemap-wrapper svg { display: block; width: 100%; height: 100%; }
.loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.7;
}
.loader {
width: 40px;
height: 40px;
border: 3px solid var(--theme-borde);
border-top-color: var(--theme-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
:global(.treemap-node) {
transition: opacity 0.15s ease;
}
:global(.treemap-node rect) {
transition: fill 0.15s ease;
opacity: 0.5;
font-size: 0.875rem;
}
.node-content {
.node-text {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
font-family: var(--font-sans);
}
.node-pct {
font-weight: 600;
line-height: 1;
opacity: 0.9;
}
.node-name {
line-height: 1.2;
margin-top: 2px;
opacity: 0.85;
}
.node-value {
font-variant-numeric: tabular-nums;
opacity: 0.7;
margin-top: 2px;
}
.node-pct { font-weight: 700; line-height: 1; }
.node-name { line-height: 1.2; margin-top: 1px; }
/* Tooltip */
.treemap-tooltip {
.tooltip {
position: absolute;
pointer-events: none;
padding: 0.75rem 1rem;
border-radius: 10px;
padding: 0.5rem 0.75rem;
border-radius: 8px;
z-index: 10;
max-width: 280px;
background: rgba(255, 255, 255, 0.85);
max-width: 250px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
font-family: var(--font-sans);
}
:global(html.dark) .treemap-tooltip {
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
color: #f5f5f5;
display: flex;
flex-direction: column;
gap: 2px;
}
.tooltip-rank {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
margin-bottom: 0.25rem;
}
.tooltip-name {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.3;
}
.tooltip-value {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.5rem;
font-variant-numeric: tabular-nums;
}
.tooltip-pct {
font-size: 0.75rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* Ranking list */
.ranking-section {
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 12px;
padding: 1.5rem;
:global(html:not(.dark)) .tooltip {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 1rem 0;
}
.tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
.tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
.tooltip-name { font-size: 0.8125rem; font-weight: 600; }
.tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
.ranking-list {
/* Leyenda */
.legend {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding-top: 0.5rem;
flex-shrink: 0;
}
.ranking-item {
display: grid;
grid-template-columns: 2.5rem 1fr;
gap: 0.75rem;
.legend-item {
display: flex;
align-items: center;
}
.ranking-pos {
font-size: 0.875rem;
font-weight: 600;
gap: 0.25rem;
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.ranking-bar-container {
position: relative;
height: 32px;
background: var(--theme-fill);
border-radius: 4px;
overflow: hidden;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.ranking-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.ranking-info {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
/* Responsive */
@media (max-width: 900px) {
.layout {
padding: 70px 1rem 1rem;
height: auto;
min-height: 100vh;
overflow: visible;
}
.ranking-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
.treemap-wrapper {
min-height: 350px;
height: 50vh;
flex: none;
}
}
.ranking-value {
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-titulo);
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.layout { padding: 60px 0.75rem 1rem; }
.title { font-size: 1.375rem; }
.subtitle { font-size: 0.75rem; }
.meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.ranking-pct {
opacity: 0.6;
font-weight: 400;
}
.year-selector {
flex-wrap: wrap;
gap: 0.25rem;
}
.ranking-more {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
text-align: center;
margin: 0.5rem 0 0 0;
}
.year-btn {
font-size: 0.625rem;
padding: 0.2rem 0.5rem;
}
/* Responsive */
@media (max-width: 640px) {
.page-container {
padding: 1rem;
.slider-row {
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.header-title {
font-size: 1.25rem;
.stats { font-size: 0.6875rem; }
.treemap-wrapper {
min-height: 300px;
height: 45vh;
}
.stats-bar {
flex-wrap: wrap;
gap: 1rem;
.tooltip {
max-width: 200px;
font-size: 0.75rem;
}
.stat-value {
font-size: 1.1rem;
.header-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 380px) {
.layout { padding: 55px 0.5rem 0.75rem; }
.title { font-size: 1.125rem; }
.ranking-item {
grid-template-columns: 2rem 1fr;
.treemap-wrapper {
min-height: 250px;
height: 40vh;
}
.ranking-name {
max-width: 50%;
.year-btn {
font-size: 0.5625rem;
padding: 0.15rem 0.375rem;
}
}
</style>
......
import { redirect } from '@sveltejs/kit';
export function load({ params }) {
throw redirect(301, `/objeto/${params.codigo}?entidad=${params.entidad}`);
}
......@@ -25,6 +25,7 @@
let clasificadorSeleccionado = $state('total'); // total, objeto, finfun, acteco
let subPartidaSearch = $state('');
let subPartidaSeleccionada = $state(null);
let mapLinkCopied = $state(false);
const CLASIFICADORES_MAPA = [
{ id: 'total', label: 'Gasto total', disponible: true },
......@@ -128,7 +129,7 @@
const res = await fetch(`${apiEndpoint}?${params}`);
const data = await res.json();
const mapped = (data || [])
.filter(d => d.ubigeo !== '0.0.0')
.filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo))
.map(d => ({
codigo: d.ubigeo,
desc: d.desc_ubigeo,
......@@ -393,11 +394,19 @@
goto(url.toString(), { replaceState: true, noScroll: true });
}
// Resolver nombre de partida por código desde Typesense
// Resolver nombre de partida por código desde la API
async function resolverNombre(clas, codigo) {
const cfg = CLAS_SEARCH_MAP[clas];
if (!cfg) return codigo;
try {
if (clas === 'objeto') {
const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=clasificador`);
if (res.ok) {
const data = await res.json();
if (data?.desc_objeto) return data.desc_objeto;
}
}
// Fallback: buscar en Typesense
const cfg = CLAS_SEARCH_MAP[clas];
if (!cfg) return codigo;
const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ });
const res = await fetch(`/api/search?${params}`);
if (!res.ok) return codigo;
......@@ -477,10 +486,34 @@
<div class="mapa-fullscreen">
<div class="mapa-top-controls">
<nav class="breadcrumb">
<button class="back-btn" onclick={() => { history.back(); }}>←</button>
<a href="/">Inicio</a>
<span class="sep">/</span>
<span>Ubicación geográfica</span>
{#if clasSeleccionado && clasificadorSeleccionado === 'objeto'}
<a href="/objeto/{clasSeleccionado.codigo}" class="back-link-mapa">
<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>
Volver a {clasSeleccionado.nombre}
</a>
{:else if clasSeleccionado && clasificadorSeleccionado === 'finfun'}
<a href="/finfun/{clasSeleccionado.codigo}" class="back-link-mapa">
<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>
Volver a {clasSeleccionado.nombre}
</a>
{:else}
<span class="mapa-location-label">Ubicación geográfica</span>
{/if}
<button class="copy-link-btn-mapa" onclick={() => {
navigator.clipboard.writeText(window.location.href);
mapLinkCopied = true;
setTimeout(() => mapLinkCopied = false, 2000);
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if mapLinkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
{mapLinkCopied ? 'Copiado' : 'Compartir'}
</button>
</nav>
<!-- Fila 1: Dropdowns inline -->
<div class="titulo-dropdowns">
......@@ -527,7 +560,6 @@
<div class="objeto-search-mapa">
{#if clasSeleccionado}
<div class="objeto-selected-pill">
<span class="objeto-pill-code">{clasSeleccionado.codigo}</span>
<span class="objeto-pill-name">{clasSeleccionado.nombre}</span>
<button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button>
</div>
......@@ -769,12 +801,41 @@
flex-shrink: 0;
}
.breadcrumb { font-size: 0.7rem; color: var(--theme-texto); opacity: 0.5; display: flex; align-items: center; gap: 0.3rem; }
.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; }
.back-btn:hover { opacity: 1; }
.breadcrumb a { color: var(--theme-texto); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; opacity: 1; }
.breadcrumb .sep { margin: 0 0.3rem; }
.breadcrumb { font-size: 0.7rem; color: var(--theme-texto); display: flex; align-items: center; gap: 0.75rem; }
.back-link-mapa {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
text-decoration: none;
transition: opacity 0.15s;
}
.back-link-mapa:hover { opacity: 0.7; }
.mapa-location-label {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
}
.copy-link-btn-mapa {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
margin-left: auto;
}
.copy-link-btn-mapa:hover { opacity: 1; }
.titulo-dropdowns {
display: flex;
......