Rafael Lopez

jhola

Showing 53 changed files with 1577 additions and 427 deletions
......@@ -4,6 +4,7 @@
let {
data = [],
hoveredYear = $bindable(null),
lockedYear = $bindable(null),
height = 220,
fill = false,
marginTop = 20,
......@@ -12,6 +13,17 @@
marginLeft = 50
} = $props();
// El año activo es el fijado o el hover
let activeYear = $derived(lockedYear ?? hoveredYear);
function handleBarClick(año) {
if (lockedYear === año) {
lockedYear = null; // desfijar
} else {
lockedYear = año; // fijar
}
}
// Medir altura real del contenedor cuando fill=true
let wrapperEl = $state(null);
let measuredHeight = $state(height);
......@@ -97,24 +109,41 @@
{/each}
<!-- Barras -->
<div class="bars" onmouseleave={() => hoveredYear = null} role="group">
<div class="bars" class:has-active={!!lockedYear} onmouseleave={() => { if (!lockedYear) hoveredYear = null; }} role="group">
{#each data as d}
{@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}
onmouseenter={() => { if (!lockedYear) hoveredYear = d.año; }}
onclick={() => handleBarClick(d.año)}
role="button"
tabindex="0"
>
<div
class="bar"
class:hovered={hoveredYear === d.año}
class:hovered={activeYear === d.año}
class:locked={lockedYear === d.año}
style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
></div>
</div>
{/each}
</div>
<!-- Hint -->
<div class="chart-hint">
{#if lockedYear}
<span class="hint-locked">
{lockedYear} fijado
<button class="hint-unlock" onclick={() => lockedYear = null}>soltar</button>
</span>
{:else if activeYear}
<span class="hint-hover hint-desktop-only">Click para fijar {activeYear}</span>
{:else}
<span class="hint-idle hint-desktop-only">Pasa el cursor sobre las barras</span>
<span class="hint-idle hint-mobile-only">Toca una barra para explorar</span>
{/if}
</div>
</div>
<!-- Eje X -->
......@@ -122,7 +151,7 @@
{#each data as d, i}
<span
class="x-label visible"
class:hovered={hoveredYear === d.año}
class:hovered={activeYear === d.año}
>
{String(d.año).slice(-2)}
</span>
......@@ -183,7 +212,7 @@
display: flex;
align-items: flex-end;
justify-content: center;
cursor: default;
cursor: pointer;
}
.bar {
......@@ -198,14 +227,61 @@
background: rgba(107, 159, 212, 0.85);
}
/* Fade en hover */
.bars:hover .bar {
opacity: 0.35;
}
.bars:hover .bar.hovered {
opacity: 1;
}
/* Fade permanente cuando hay año fijado */
.bars.has-active .bar {
opacity: 0.25;
}
.bars.has-active .bar.locked {
opacity: 1;
box-shadow: 0 0 0 2px var(--theme-accent, #C9A751);
}
/* Chart hint */
.chart-hint {
position: absolute;
top: -2px;
right: 0;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
}
.hint-locked {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--theme-accent, #C9A751);
opacity: 1;
}
.hint-unlock {
background: none;
border: none;
border-bottom: 1px dotted currentColor;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
opacity: 0.7;
transition: opacity 0.15s;
}
.hint-unlock:hover {
opacity: 1;
}
.hint-mobile-only { display: none; }
.hint-desktop-only { display: inline; }
@media (max-width: 768px) {
.hint-mobile-only { display: inline; }
.hint-desktop-only { display: none; }
}
.x-axis {
position: absolute;
bottom: 0;
......
......@@ -78,7 +78,6 @@
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => selectEntity(entity)}
>
<span class="item-code">{entity.entidad}</span>
<span class="item-name">{entity.entidad_desc}</span>
</button>
{/each}
......
......@@ -5,6 +5,8 @@
let isDark = $state(false);
let isMac = $state(false);
let canGoBack = $state(false);
let canGoForward = $state(false);
function toggleTheme() {
isDark = !isDark;
......@@ -12,14 +14,39 @@
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
function goBack() { history.back(); }
function goForward() { history.forward(); }
function updateNavState() {
// history.length > 1 means there's something to go back to
// We track forward availability via sessionStorage
canGoBack = history.length > 1 && sessionStorage.getItem('nav_depth') > 0;
canGoForward = sessionStorage.getItem('nav_forward') === 'true';
}
onMount(() => {
// Detect Mac for keyboard shortcut display
isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
// Read actual state from document (set by app.html)
isDark = document.documentElement.classList.contains('dark');
// Listen for theme changes (e.g., from drawer on mobile)
// Track navigation depth for back/forward state
let depth = parseInt(sessionStorage.getItem('nav_depth') || '0');
// On first visit, depth is 0
// We increment on each navigation
depth++;
sessionStorage.setItem('nav_depth', depth.toString());
canGoBack = depth > 1;
// Listen for popstate (back/forward browser actions) to enable forward
const handlePopstate = () => {
sessionStorage.setItem('nav_forward', 'true');
canGoForward = true;
const d = parseInt(sessionStorage.getItem('nav_depth') || '1');
sessionStorage.setItem('nav_depth', Math.max(0, d - 1).toString());
canGoBack = d - 1 > 0;
};
window.addEventListener('popstate', handlePopstate);
// Listen for theme changes
const observer = new MutationObserver(() => {
isDark = document.documentElement.classList.contains('dark');
});
......@@ -28,12 +55,27 @@
attributeFilter: ['class']
});
return () => observer.disconnect();
return () => {
observer.disconnect();
window.removeEventListener('popstate', handlePopstate);
};
});
</script>
<!-- MÓVIL: Home + Búsqueda + Theme + hamburguesa arriba a la derecha -->
<!-- MÓVIL: Nav + Home + Búsqueda + Theme + hamburguesa -->
<div class="navbar-mobile">
<div class="nav-arrows">
<button onclick={goBack} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoBack} aria-label="Atrás" disabled={!canGoBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
<button onclick={goForward} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoForward} aria-label="Adelante" disabled={!canGoForward}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</div>
<a href="/" class="nav-home-link" aria-label="Ir al inicio">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
......@@ -76,10 +118,24 @@
</button>
</div>
<!-- DESKTOP: Barra completa con Home + Search + Theme + Menu -->
<!-- DESKTOP: Barra completa con Nav + Home + Search + Theme + Menu -->
<div class="navbar-desktop">
<div class="navbar-desktop-inner">
<!-- FLECHAS NAVEGACIÓN -->
<div class="nav-arrows">
<button onclick={goBack} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoBack} aria-label="Atrás" disabled={!canGoBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
<button onclick={goForward} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoForward} aria-label="Adelante" disabled={!canGoForward}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</div>
<!-- BOTÓN HOME -->
<div class="relative group/home">
<a href="/" class="nav-btn-icon" aria-label="Ir al inicio">
......@@ -390,4 +446,45 @@
background: rgba(0, 0, 0, 0.12);
color: #1A1A18;
}
/* Navigation arrows */
.nav-arrows {
display: flex;
align-items: center;
gap: 2px;
margin-right: 2px;
}
.nav-arrow-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
border-radius: 8px;
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-arrow-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #F5F0E8;
}
.nav-arrow-disabled {
opacity: 0.2;
cursor: default;
pointer-events: none;
}
:global(html:not(.dark)) .nav-arrow-btn {
color: #888;
}
:global(html:not(.dark)) .nav-arrow-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #1A1A18;
}
</style>
......
......@@ -25,7 +25,6 @@
{ id: 'sectores', label: '¿En qué sectores?', tecnico: 'Sectores económicos' },
{ id: 'rubro', label: '¿Con qué recursos?', tecnico: 'Rubros' },
{ id: 'organismo', label: '¿Quién financia?', tecnico: 'Organismos' },
{ id: 'fuente', label: 'Origen del ingreso', tecnico: 'Fuentes' },
];
function toggleClassifier(id) {
......@@ -40,7 +39,7 @@
}
// Clasificadores seleccionados → class_ param para Typesense
const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco' };
const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
function getActiveClasses() {
if (selectedClassifiers.length === 0) return [];
......@@ -102,6 +101,16 @@
} else if (isClass && cls === 'ubigeo') {
codigo = String(meta.municipio_ubigeo || '');
tipo = 'ubigeo';
} else if (isClass && cls === 'acteco') {
const s = meta.acteco_sector != null ? String(Math.round(meta.acteco_sector)) : '';
if (s) {
codigo = s;
if (meta.acteco_subsector != null) {
codigo += '.' + Math.round(meta.acteco_subsector);
if (meta.acteco_actividad != null) codigo += '.' + Math.round(meta.acteco_actividad);
}
}
tipo = 'acteco';
} else if (!isClass) {
// Programa/proyecto
tipo = 'programa';
......@@ -200,9 +209,9 @@
entidad: '/entidad/',
objeto_gasto: '/objeto/',
finfun: '/finfun/',
acteco: '/acteco/',
rubro: '/rubro/',
organismo: '/organismo/',
fuente: '/fuente/',
ubigeo: '/ubicacion/',
programa: '/proyecto/'
};
......@@ -279,7 +288,7 @@
bind:this={searchInput}
type="text"
class="search-modal-input"
placeholder={searchMode === 'programas' ? 'Buscar programas y proyectos...' : selectedClassifiers.length > 0 ? 'Buscar en clasificadores seleccionados...' : 'Selecciona al menos un clasificador...'}
placeholder={searchMode === 'todo' ? 'Buscar en todo...' : searchMode === 'programas' ? 'Buscar programas y proyectos...' : selectedClassifiers.length > 0 ? 'Buscar en clasificadores seleccionados...' : 'Selecciona al menos un clasificador...'}
value={searchVal}
oninput={handleInput}
/>
......@@ -291,8 +300,11 @@
<!-- Mode toggle + classifier pills -->
<div class="search-modal-filters">
<div class="mode-toggle">
<button class="mode-btn" class:active={searchMode === 'todo'} onclick={() => { searchMode = 'todo'; selectedClassifiers = []; landingSearchMode.set('todo'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}>
Todo
</button>
<button class="mode-btn" class:active={searchMode === 'programas'} onclick={() => { searchMode = 'programas'; selectedClassifiers = []; landingSearchMode.set('programas'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}>
Programas
Programas y Proyectos
</button>
<button class="mode-btn" class:active={searchMode === 'clasificadores'} onclick={() => { searchMode = 'clasificadores'; landingSearchMode.set('clasificadores'); if (searchVal.length >= 2) doSearch(searchVal); }}>
Clasificadores
......
This diff is collapsed. Click to expand it.
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/acteco';
// Jerarquía acteco: códigos con punto "1" → "1.1" → "1.1.1"
// Todos tienen nivel "actividad" en la API, derivamos la profundidad del código
function getDepth(codigo) {
return String(codigo).split('.').length;
}
function getParentCode(codigo) {
const parts = String(codigo).split('.');
if (parts.length <= 1) return null;
return parts.slice(0, -1).join('.');
}
export async function load({ params }) {
const { codigo } = params;
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Sector económico no encontrado');
const acteco = await res.json();
let padres = [];
let hijos = [];
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
const depth = getDepth(codigo);
// Padres: todos los ancestros
if (depth > 1) {
const parts = String(codigo).split('.');
const parentCodes = [];
for (let i = 1; i < parts.length; i++) {
parentCodes.push(parts.slice(0, i).join('.'));
}
padres = clasificador.filter(c => parentCodes.includes(c.acteco));
}
// Hijos directos: un nivel más de profundidad, mismo prefijo
const prefix = codigo + '.';
const childDepth = depth + 1;
hijos = clasificador.filter(c =>
c.acteco.startsWith(prefix) && getDepth(c.acteco) === childDepth
);
padres.sort((a, b) => a.acteco.localeCompare(b.acteco));
hijos.sort((a, b) => a.acteco.localeCompare(b.acteco));
}
} catch { /* clasificador optional */ }
return {
acteco,
padres,
hijos
};
}
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
const API_BASE = 'http://136.112.29.74/api/acteco/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/acteco';
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`;
} 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/acteco';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(acteco) {
const idx = acteco.lastIndexOf('.');
if (idx === -1) return null;
return acteco.substring(0, idx);
}
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
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.acteco}`);
if (!res.ok) return null;
const data = await res.json();
return { acteco: item.acteco, estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.acteco, 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') {
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const estados = cachedEstados.get(item.acteco);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel || 'actividad',
acteco: item.acteco,
desc_acteco: item.desc_acteco,
parent: getParent(item.acteco),
devengado: yearData.total
});
}
// Agregar nodos padres faltantes (sectores sin estados propios)
const ids = new Set(results.map(r => r.acteco));
const missingParents = new Set();
results.forEach(r => {
if (r.parent && !ids.has(r.parent)) missingParents.add(r.parent);
});
for (const parentCode of missingParents) {
const item = cachedClasificador.find(c => c.acteco === parentCode);
results.push({
gestion,
nivel: item?.nivel || 'actividad',
acteco: parentCode,
desc_acteco: item?.desc_acteco || parentCode,
parent: getParent(parentCode),
devengado: 0
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
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.acteco}/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 || 'actividad',
acteco: item.acteco,
desc_acteco: item.desc_acteco,
parent: getParent(item.acteco),
devengado: yearData.monto
};
} catch { return null; }
}));
results.push(...batchResults.filter(Boolean));
}
// Agregar nodos padres faltantes
const ids2 = new Set(results.map(r => r.acteco));
const missing2 = new Set();
results.forEach(r => {
if (r.parent && !ids2.has(r.parent)) missing2.add(r.parent);
});
for (const parentCode of missing2) {
const item = cachedClasificador.find(c => c.acteco === parentCode);
results.push({
gestion,
nivel: item?.nivel || 'actividad',
acteco: parentCode,
desc_acteco: item?.desc_acteco || parentCode,
parent: getParent(parentCode),
devengado: 0
});
}
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' }
});
}
}
......@@ -6,8 +6,7 @@ export async function GET({ url }) {
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
......@@ -18,8 +17,7 @@ export async function GET({ url }) {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
......@@ -28,8 +26,7 @@ export async function GET({ url }) {
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......
const API_BASE = 'http://136.112.29.74/api/entidad';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'detalle';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
// For DAs (codigo contains dot like "1201.1"), extract the entidad number
const entidadNum = codigo.includes('.') ? codigo.split('.')[0] : codigo;
let apiUrl;
if (tipo === 'detalle') {
apiUrl = `${API_BASE}/${entidadNum}`;
} else if (tipo === 'objetos') {
apiUrl = `${API_BASE}/${entidadNum}/objetos`;
} else if (tipo === 'finfuns') {
apiUrl = `${API_BASE}/${entidadNum}/finfuns`;
} else if (tipo === 'actecos') {
apiUrl = `${API_BASE}/${entidadNum}/actecos`;
} else if (tipo === 'rubros') {
apiUrl = `${API_BASE}/${entidadNum}/rubros`;
} else if (tipo === 'organismos') {
apiUrl = `${API_BASE}/${entidadNum}/organismos`;
} 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/finfun/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/finfun';
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`;
} 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/finfun';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(finfun) {
const idx = finfun.lastIndexOf('.');
if (idx === -1) return null;
return finfun.substring(0, idx);
}
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
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.finfun}`);
if (!res.ok) return null;
const data = await res.json();
return { finfun: item.finfun, estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.finfun, 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') {
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const estados = cachedEstados.get(item.finfun);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel,
finfun: item.finfun,
desc_finfun: item.desc_finfun,
parent: getParent(item.finfun),
devengado: yearData.total
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
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.finfun}/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,
finfun: item.finfun,
desc_finfun: item.desc_finfun,
parent: getParent(item.finfun),
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' }
});
}
}
......@@ -6,8 +6,7 @@ export async function GET({ url }) {
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
......@@ -18,8 +17,7 @@ export async function GET({ url }) {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
......@@ -28,8 +26,7 @@ export async function GET({ url }) {
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......
......@@ -6,10 +6,13 @@ export async function GET() {
if (!res.ok) throw new Error();
const data = await res.json();
// Mapear al formato que espera el treemap
const mapped = data.map(d => ({
const mapped = data
.filter(d => d.entidad !== 0)
.map(d => ({
entidad: d.entidad,
desc_entidad: d.desc_entidad,
sigla_entidad: d.sigla_entidad || ''
sigla_entidad: d.sigla_entidad || '',
gestiones: d.gestiones || ''
}));
return new Response(JSON.stringify(mapped), {
headers: { 'Content-Type': 'application/json' }
......
const API_BASE = 'http://136.112.29.74/api/organismo/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/organismo';
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`;
} 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/organismo';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
// Organismo hierarchy: grupo → subgrupo → organismo
// Parent is derived from clasificador fields, not from code pattern
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
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 code = item.organismo;
const res = await fetch(`${API_BASE}/${code}`);
if (!res.ok) return null;
const data = await res.json();
return { code: String(code), estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.code, 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') {
await loadAndCacheAll();
const results = [];
const grupoSet = new Set();
const subgrupoSet = new Set();
for (const item of cachedClasificador) {
const code = String(item.organismo);
const estados = cachedEstados.get(code);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
const grupoKey = `g_${item.organismo_grupo}`;
const subgrupoKey = `sg_${item.organismo_grupo}_${item.organismo_subgrupo}`;
// Add grupo node if not yet added
if (!grupoSet.has(grupoKey)) {
grupoSet.add(grupoKey);
results.push({
gestion,
nivel: 'grupo',
organismo: grupoKey,
desc_organismo: item.desc_organismo_grupo || `Grupo ${item.organismo_grupo}`,
parent: null,
devengado: 0
});
}
// Add subgrupo node if not yet added
if (!subgrupoSet.has(subgrupoKey)) {
subgrupoSet.add(subgrupoKey);
results.push({
gestion,
nivel: 'subgrupo',
organismo: subgrupoKey,
desc_organismo: item.desc_organismo_subgrupo || `Subgrupo ${item.organismo_subgrupo}`,
parent: grupoKey,
devengado: 0
});
}
results.push({
gestion,
nivel: 'organismo',
organismo: code,
desc_organismo: item.desc_organismo || '',
parent: subgrupoKey,
devengado: yearData.total
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const grupoSet2 = new Set();
const subgrupoSet2 = new Set();
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 code = String(item.organismo);
const res = await fetch(`${API_BASE}/${code}/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 { item, code, monto: yearData.monto };
} catch { return null; }
}));
batchResults.filter(Boolean).forEach(({ item, code, monto }) => {
const grupoKey = `g_${item.organismo_grupo}`;
const subgrupoKey = `sg_${item.organismo_grupo}_${item.organismo_subgrupo}`;
if (!grupoSet2.has(grupoKey)) {
grupoSet2.add(grupoKey);
results.push({ gestion, nivel: 'grupo', organismo: grupoKey, desc_organismo: item.desc_organismo_grupo || '', parent: null, devengado: 0 });
}
if (!subgrupoSet2.has(subgrupoKey)) {
subgrupoSet2.add(subgrupoKey);
results.push({ gestion, nivel: 'subgrupo', organismo: subgrupoKey, desc_organismo: item.desc_organismo_subgrupo || '', parent: grupoKey, devengado: 0 });
}
results.push({ gestion, nivel: 'organismo', organismo: code, desc_organismo: item.desc_organismo || '', parent: subgrupoKey, devengado: monto });
});
}
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' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/rubro/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/rubro';
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`;
} 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/rubro';
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(code, nivel) {
const s = String(code).padStart(5, '0');
if (nivel === 'tipo') return null;
// clase (ABC00) → tipo (AB000)
if (nivel === 'clase') return s.substring(0, 2) + '000';
// cuenta (ABCD0) → clase (ABC00)
if (nivel === 'cuenta') return s.substring(0, 3) + '00';
// sub_cuenta (ABCDE) → cuenta (ABCD0)
if (nivel === 'sub_cuenta') return s.substring(0, 4) + '0';
return null;
}
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
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 code = item.rubro;
const res = await fetch(`${API_BASE}/${code}`);
if (!res.ok) return null;
const data = await res.json();
return { code: String(code), estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.code, 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') {
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const code = String(item.rubro);
const estados = cachedEstados.get(code);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel || yearData.nivel || '',
rubro: code,
desc_rubro: item.desc_rubro || '',
parent: getParent(code, item.nivel || yearData.nivel),
devengado: yearData.total
});
}
// Add missing parent nodes (tipos without estados)
const ids = new Set(results.map(r => r.rubro));
const missingParents = new Set();
results.forEach(r => {
if (r.parent && !ids.has(r.parent)) missingParents.add(r.parent);
});
for (const parentCode of missingParents) {
const item = cachedClasificador.find(c => String(c.rubro) === parentCode);
results.push({
gestion,
nivel: item?.nivel || 'tipo',
rubro: parentCode,
desc_rubro: item?.desc_rubro || parentCode,
parent: getParent(parentCode, item?.nivel || 'tipo'),
devengado: 0
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
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 code = String(item.rubro);
const res = await fetch(`${API_BASE}/${code}/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 || '',
rubro: code,
desc_rubro: item.desc_rubro || '',
parent: getParent(code, 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' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/rubro';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
if (gestion) apiUrl.searchParams.set('gestion', gestion);
try {
const response = await fetch(apiUrl.toString());
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/ubigeo';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'detalle';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'detalle') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'objetos') {
apiUrl = `${API_BASE}/${codigo}/objetos`;
} else if (tipo === 'finfuns') {
apiUrl = `${API_BASE}/${codigo}/finfuns`;
} else if (tipo === 'actecos') {
apiUrl = `${API_BASE}/${codigo}/actecos`;
} else if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/clasificador`;
} 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' }
});
}
}
This diff could not be displayed because it is too large.
......@@ -230,8 +230,13 @@
const data = await res.json();
if (Array.isArray(data)) {
entities = data.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
console.log('Entities loaded:', entities.length);
entities = data
.filter(e => {
if (!e.gestiones) return false;
const años = e.gestiones.split(',').map(Number);
return años.includes(year);
})
.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
}
if (selectedEntity) {
......@@ -787,7 +792,7 @@
// 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);
availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= 2025).sort((a, b) => b - a);
selectedYear = availableYears[0];
} else {
availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
......@@ -1468,12 +1473,12 @@
<svelte:window onclick={handleClickOutsideEntity} />
<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo);">
<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo); padding-top: 60px;">
<!-- Header pedagógico -->
<header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 {viewMode === 'mapa' || viewMode === 'comparar' ? 'py-2' : 'py-6'}">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-2">
<!-- Breadcrumb: responsive -->
<nav class="{viewMode === 'comparar' ? 'mb-1' : 'mb-4'}" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
<nav class="mb-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
<!-- Móvil: solo padre -->
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
......@@ -1487,25 +1492,10 @@
</nav>
<div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
<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);">
<h1 style="font-family: 'DM Serif Display', serif; font-weight: 400; font-size: 2rem; margin-bottom: 0.25rem; color: var(--theme-titulo);">
¿En qué se gasta?
</h1>
{#if viewMode === 'mapa'}
<button
onclick={() => showTreemapHelp = true}
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs transition-colors"
style="color: var(--theme-texto); background-color: var(--theme-fill);"
title="Cómo leer el gráfico"
>
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span class="hidden sm:inline">Cómo leer</span>
</button>
{/if}
<div style="margin-left: auto; display: flex; align-items: center; gap: 0.75rem;">
<div class="flex items-center gap-3">
<button class="share-btn-clas" onclick={() => {
navigator.clipboard.writeText(window.location.href);
clasLinkCopied = true;
......@@ -1521,13 +1511,6 @@
</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">
......@@ -1565,7 +1548,6 @@
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => { selectEntity(entity); titleEntityDropdownOpen = false; }}
>
<span class="option-code">{entity.entidad}</span>
<span class="option-name">{entity.desc_entidad}</span>
</button>
{/each}
......@@ -1616,7 +1598,7 @@
</div>
<!-- Controles de visualización (fuera del max-w-3xl para usar todo el ancho) -->
<div class="{viewMode === 'mapa' ? 'mt-3' : 'mt-8'}">
<div class="mt-2">
<!-- Fila principal: Toggle + Guía + Filtros -->
<div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
<!-- Selector de modo + Guía inline (visible en sm+) -->
......@@ -1770,7 +1752,7 @@
{layoutMounted ? 'sidebar-mounted' : ''}
"
>
<div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-6">
<div class="h-full lg:sticky overflow-y-auto py-4 px-4 lg:px-0 lg:pr-6" style="top: 70px; max-height: calc(100vh - 70px);">
<!-- Cerrar en móvil -->
<div class="flex justify-between items-center mb-4 lg:hidden">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de gasto</span>
......@@ -2040,7 +2022,7 @@
<!-- Sidebar derecha: Navegación rápida -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto py-8 pl-6 border-l" style="border-color: var(--theme-borde);">
<div class="sticky overflow-y-auto py-4 pl-6 border-l" style="top: 70px; max-height: calc(100vh - 70px); border-color: var(--theme-borde);">
<p class="text-sm uppercase tracking-wide mb-4 font-medium" style="color: var(--theme-texto);">Navegación</p>
{#if selectedGrupo}
<nav class="space-y-3">
......@@ -2286,7 +2268,7 @@
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }}
onmouseleave={() => { if (!pinnedNode) setTimeout(() => { if (!tooltipHovered) hoveredNode = null; }, 30); }}
onclick={(e) => handleFlatNodeClick(node, e)}
style="cursor: pointer;"
>
......@@ -2606,7 +2588,6 @@
<button class="inline-dropdown-option" class:active={!compareA.entity} onclick={() => clearCompareEntity('A')}>Todo el Estado</button>
{#each filteredEntitiesA() as entity}
<button class="inline-dropdown-option" class:active={compareA.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('A', entity)}>
<span class="font-mono text-xs opacity-50">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
</button>
{/each}
......@@ -2768,7 +2749,6 @@
<button class="inline-dropdown-option" class:active={!compareB.entity} onclick={() => clearCompareEntity('B')}>Todo el Estado</button>
{#each filteredEntitiesB() as entity}
<button class="inline-dropdown-option" class:active={compareB.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('B', entity)}>
<span class="font-mono text-xs opacity-50">{entity.entidad}</span>
<span class="truncate">{entity.desc_entidad}</span>
</button>
{/each}
......
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params, fetch }) {
......@@ -11,25 +10,17 @@ export async function load({ params, fetch }) {
throw error(400, 'Código de entidad inválido');
}
// Cargar metadata, resumen y población en paralelo
const [entidadRes, resumenRes, pobRes] = await Promise.all([
supabase
.schema('ppto')
.from('clas_institucional')
.select('*')
.eq('entidad', entidadNum)
.single(),
supabase
.schema('ppto')
.from('entidad_resumen')
.select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
.eq('codigo', codigo),
// Cargar metadata desde proxy local + población en paralelo
const [entidadRes, pobRes] = await Promise.all([
fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=detalle`).then(r => r.ok ? r.json() : null),
fetch('/poblacion.csv').then(r => r.text())
]);
// Buscar población: primero por entidad, si no suma nacional
if (!entidadRes) {
throw error(404, 'Entidad no encontrada');
}
// Población
const poblacionEntidad = {};
const poblacionNacional = {};
pobRes.split('\n').slice(1).forEach(line => {
......@@ -37,51 +28,79 @@ export async function load({ params, fetch }) {
const g = parseInt(gestion);
const p = parseInt(pob);
if (!g || !p) return;
// Suma nacional
poblacionNacional[g] = (poblacionNacional[g] || 0) + p;
// Por entidad (código sin DA)
if (ent === entidadCode) {
poblacionEntidad[g] = (poblacionEntidad[g] || 0) + p;
}
});
// Usar población de la entidad si existe, si no la nacional
const tienePobEntidad = Object.keys(poblacionEntidad).length > 0;
const poblacionMap = tienePobEntidad ? poblacionEntidad : poblacionNacional;
if (entidadRes.error || !entidadRes.data) {
throw error(404, 'Entidad no encontrada');
}
// gastos_ingresos → resumenData (compatible con el formato que espera la página)
const gastosIngresos = entidadRes.gastos_ingresos || [];
const resumenData = gastosIngresos.map(d => ({
tipo: d.tipo,
gestion: d.gestion,
devengado: d.devengado,
ranking: d.ranking,
codigo: codigo,
desc: entidadRes.desc_entidad
}));
// Determinar última gestión disponible
const gestiones = [...new Set((resumenRes.data || []).map(d => d.gestion))].sort((a, b) => b - a);
// Determinar última gestión
const gestiones = [...new Set(gastosIngresos.map(d => d.gestion))].filter(g => g <= 2025).sort((a, b) => b - a);
const ultimaGestion = gestiones[0] || 2025;
// Cargar distribuciones solo de la última gestión
const distRes = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigo)
.eq('gestion', ultimaGestion);
// Cargar distribuciones de la última gestión (objetos, finfuns, etc.)
const distEndpoints = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' },
{ api: 'rubros', dim: 'rubro' },
{ api: 'organismos', dim: 'organismo' }
];
const distResults = await Promise.all(
distEndpoints.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=${api}`);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter(d => d.gestion === ultimaGestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
const distribucionesData = distResults.flat();
// Resolver nombre de DA desde el resumen
// DA info
let nombreDA = null;
let nombreEntidadMadre = null;
if (isDA && resumenRes.data?.length > 0) {
const firstRow = resumenRes.data[0];
nombreDA = firstRow.desc || null;
nombreEntidadMadre = firstRow.desc_padre || null;
if (isDA) {
nombreDA = entidadRes.desc_entidad || null;
nombreEntidadMadre = entidadRes.desc_entidad || null;
}
return {
entidad: entidadRes.data,
entidad: entidadRes,
isDA,
nombreDA,
nombreEntidadMadre,
codigoEntidadPadre: isDA ? entidadCode : null,
resumenData: resumenRes.data || [],
distribucionesData: distRes.data || [],
resumenData,
distribucionesData,
gestionInicial: ultimaGestion,
poblacionMap,
tienePobEntidad
......
......@@ -2,7 +2,6 @@
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
let { data } = $props();
let entidad = $derived(data.entidad);
......@@ -37,15 +36,43 @@
return;
}
cargandoDist = true;
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigoSeleccionado)
.eq('gestion', gestion);
const result = rows || [];
const entidadNum = codigoSeleccionado.includes('.') ? codigoSeleccionado.split('.')[0] : codigoSeleccionado;
const dims = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' },
{ api: 'rubros', dim: 'rubro' },
{ api: 'organismos', dim: 'organismo' }
];
try {
const results = await Promise.all(
dims.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=${api}`);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter(d => d.gestion === gestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
const result = results.flat();
distCache[gestion] = result;
distribucionesData = result;
} catch {
distribucionesData = [];
}
cargandoDist = false;
}
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código finfun
// Jerarquía: Finalidad (1-9 o 10) → Grupo Función (2-3 dígitos) → Función (3+ dígitos)
// Nota: Finalidad 10 es especial - tiene 2 dígitos, sus hijos empiezan con "10"
const API_BASE = 'http://136.112.29.74/api/finfun';
function getFinalidadCode(finfun) {
const code = String(finfun);
// Si empieza con "10", la finalidad es "10"
if (code.startsWith('10')) {
return '10';
}
// Si no, la finalidad es el primer dígito
return code.charAt(0);
// Jerarquía finfun: Finalidad → Grupo Función → Función
// Códigos con punto: "1" (finalidad), "1.1" (grpfuncion), "1.1.1" (función)
function getNivel(codigo) {
const parts = String(codigo).split('.');
if (parts.length === 1) return 'finalidad';
if (parts.length === 2) return 'grpfuncion';
return 'funcion';
}
function getGrpFuncionCode(finfun) {
const code = String(finfun);
// Si empieza con "10", el grupo función son los primeros 3 dígitos (ej: "101", "102")
if (code.startsWith('10')) {
return code.substring(0, 3);
}
// Si no, son los primeros 2 dígitos (ej: "11", "21", "93")
return code.substring(0, 2);
function getFinalidadCode(codigo) {
return String(codigo).split('.')[0];
}
function getNivel(finfun) {
const code = String(finfun);
// Finalidades 1-9 tienen longitud 1, finalidad 10 tiene longitud 2
if (code.length === 1) return 'finalidad';
if (code === '10') return 'finalidad';
// GrpFuncion: 2 dígitos para 1-9, 3 dígitos para 10
if (code.startsWith('10')) {
if (code.length === 3) return 'grpfuncion';
return 'funcion';
}
if (code.length === 2) return 'grpfuncion';
return 'funcion';
function getGrpFuncionCode(codigo) {
const parts = String(codigo).split('.');
return parts.slice(0, 2).join('.');
}
export async function load({ params }) {
console.time('[SERVER] Total load finfun');
const { codigo } = params;
console.time('[SERVER] Query clas_finfun');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('finfun', codigo);
console.timeEnd('[SERVER] Query clas_finfun');
if (dbError || !data || data.length === 0) {
throw error(404, 'Finalidad/Función no encontrada');
}
// Cargar datos del finfun desde la API
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Finalidad/Función no encontrada');
const finfun = await res.json();
// Tomar el primer resultado
const finfun = data[0];
const nivel = finfun.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
// Cargar clasificador completo para derivar padres e hijos
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
// Buscar padres según nivel
// Padres
if (nivel === 'funcion') {
// Padres: finalidad y grupo función
const finalidadCode = getFinalidadCode(codigo);
const grpFuncionCode = getGrpFuncionCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.in('finfun', [finalidadCode, grpFuncionCode])
.order('finfun');
if (padresData) padres = padresData;
const grpCode = getGrpFuncionCode(codigo);
padres = clasificador.filter(c => c.finfun === finalidadCode || c.finfun === grpCode);
} else if (nivel === 'grpfuncion') {
// Padre: finalidad
const finalidadCode = getFinalidadCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('finfun', finalidadCode);
if (padresData) padres = padresData;
padres = clasificador.filter(c => c.finfun === finalidadCode);
}
// finalidad no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
// Hijos
if (nivel === 'finalidad') {
// Hijos: grupos de función de esta finalidad
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'grpfuncion')
.order('finfun');
// Filtrar solo los hijos directos de esta finalidad
// Para finalidad "1": hijos son "11", "12", ..., "19" (NO "10x")
// Para finalidad "10": hijos son "101", "102", ..., "109"
if (hijosData) {
hijos = hijosData.filter(h => {
const hijoPadre = getFinalidadCode(h.finfun);
return hijoPadre === codigo;
});
}
hijos = clasificador.filter(c =>
c.nivel === 'grpfuncion' && getFinalidadCode(c.finfun) === codigo && c.finfun !== codigo
);
} else if (nivel === 'grpfuncion') {
// Hijos: funciones de este grupo
const grpCode = getGrpFuncionCode(codigo);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'funcion')
.order('finfun');
// Filtrar solo los hijos directos de este grupo
if (hijosData) {
hijos = hijosData.filter(h => {
const hijoGrp = getGrpFuncionCode(h.finfun);
return hijoGrp === grpCode;
});
hijos = clasificador.filter(c =>
c.nivel === 'funcion' && getGrpFuncionCode(c.finfun) === codigo
);
}
}
// funcion no tiene hijos
console.timeEnd('[SERVER] Query hijos');
padres.sort((a, b) => a.finfun.localeCompare(b.finfun));
hijos.sort((a, b) => a.finfun.localeCompare(b.finfun));
}
} catch { /* clasificador optional */ }
console.timeEnd('[SERVER] Total load finfun');
return {
finfun,
padres,
......
This diff could not be displayed because it is too large.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
......@@ -46,7 +46,10 @@
// 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);
if (objetoInfo.estados?.length > 0) {
return objetoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
}
return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
});
// Formatters
......@@ -285,12 +288,6 @@
</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>
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/organismo';
export async function load({ params }) {
console.time('[SERVER] Total load organismo');
const { codigo } = params;
console.time('[SERVER] Query clas_organismos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('*')
.eq('organismo', codigo);
console.timeEnd('[SERVER] Query clas_organismos');
if (dbError || !data || data.length === 0) {
throw error(404, 'Organismo no encontrado');
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Organismo no encontrado');
const organismo = await res.json();
let padres = [];
let hijos = [];
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
const current = clasificador.find(c => c.organismo === parseInt(codigo));
if (current) {
// Padres: grupo y subgrupo (como objetos virtuales para navegación)
padres = [
{ organismo: `grupo_${current.organismo_grupo}`, desc_organismo: current.desc_organismo_grupo, nivel: 'grupo', sigla: current.sigla_organismo_grupo },
{ organismo: `subgrupo_${current.organismo_grupo}_${current.organismo_subgrupo}`, desc_organismo: current.desc_organismo_subgrupo, nivel: 'subgrupo', sigla: current.sigla_organismo_subgrupo }
];
// Hijos: otros organismos del mismo subgrupo
hijos = clasificador.filter(c =>
c.organismo_grupo === current.organismo_grupo &&
c.organismo_subgrupo === current.organismo_subgrupo &&
c.organismo !== current.organismo &&
c.organismo !== 0
).sort((a, b) => (a.desc_organismo || '').localeCompare(b.desc_organismo || ''));
}
const organismo = data[0];
// Obtener hermanos (otros organismos del mismo subgrupo)
let hermanos = [];
console.time('[SERVER] Query hermanos');
const { data: hermanosData } = await supabase
.schema('ppto')
.from('clas_organismos')
.select('*')
.eq('organismo_grupo', organismo.organismo_grupo)
.eq('organismo_sub_grupo', organismo.organismo_sub_grupo)
.neq('organismo', codigo)
.order('organismo');
if (hermanosData) {
hermanos = hermanosData;
}
console.timeEnd('[SERVER] Query hermanos');
} catch { /* optional */ }
console.timeEnd('[SERVER] Total load organismo');
return {
organismo,
hermanos
};
return { organismo, padres, hijos };
}
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código rubro
// Jerarquía: Tipo (XX000) → Clase (XXX00) → Cuenta (XXXX0) → Subcuenta (XXXXX)
function getTipoCode(rubro) {
// Primeros 2 dígitos + 000 → ej: 11000, 12000
return rubro.substring(0, 2) + '000';
}
function getClaseCode(rubro) {
// Primeros 3 dígitos + 00 → ej: 11100, 11200
return rubro.substring(0, 3) + '00';
}
function getCuentaCode(rubro) {
// Primeros 4 dígitos + 0 → ej: 11110, 11120
return rubro.substring(0, 4) + '0';
}
function getNivelFromCode(rubro) {
// Determinar nivel basado en el patrón de ceros
if (rubro.endsWith('000')) return 'tipo'; // XX000
if (rubro.endsWith('00')) return 'clase'; // XXX00
if (rubro.endsWith('0')) return 'cuenta'; // XXXX0
return 'sub_cuenta'; // XXXXX
}
function getNextNivel(nivel) {
const niveles = { tipo: 'clase', clase: 'cuenta', cuenta: 'sub_cuenta' };
return niveles[nivel] || null;
}
const API_BASE = 'http://136.112.29.74/api/rubro';
export async function load({ params }) {
const { codigo } = params;
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('rubro', codigo);
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Rubro no encontrado');
const rubro = await res.json();
if (dbError || !data || data.length === 0) {
throw error(404, 'Rubro no encontrado');
}
const rubro = data[0];
const nivel = rubro.nivel || getNivelFromCode(codigo);
// Obtener jerarquía (padres e hijos)
const nivel = rubro.nivel;
let padres = [];
let hijos = [];
// Buscar padres según nivel
if (nivel === 'sub_cuenta') {
// Padres: tipo, clase, cuenta
const tipoCode = getTipoCode(codigo);
const claseCode = getClaseCode(codigo);
const cuentaCode = getCuentaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.in('rubro', [tipoCode, claseCode, cuentaCode])
.order('rubro');
if (padresData) padres = padresData;
} else if (nivel === 'cuenta') {
// Padres: tipo, clase
const tipoCode = getTipoCode(codigo);
const claseCode = getClaseCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.in('rubro', [tipoCode, claseCode])
.order('rubro');
if (padresData) padres = padresData;
} else if (nivel === 'clase') {
// Padre: tipo
const tipoCode = getTipoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('rubro', tipoCode);
if (padresData) padres = padresData;
}
// tipo no tiene padres
// Buscar hijos según nivel
const nextNivel = getNextNivel(nivel);
if (nextNivel) {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', nextNivel)
.like('rubro', `${codigo.replace(/0+$/, '')}%`)
.order('rubro');
try {
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (clasRes.ok) {
const clasificador = await clasRes.json();
const s = String(codigo).padStart(5, '0');
// Rubro codes are 5 digits: ABCDE
// tipo = AB000, clase = ABC00, cuenta = ABCD0, sub_cuenta = ABCDE
const tipoCode = s.substring(0, 2) + '000';
const claseCode = s.substring(0, 3) + '00';
const cuentaCode = s.substring(0, 4) + '0';
// Padres según nivel
const parentCodes = [];
if (nivel === 'sub_cuenta') parentCodes.push(cuentaCode, claseCode, tipoCode);
else if (nivel === 'cuenta') parentCodes.push(claseCode, tipoCode);
else if (nivel === 'clase') parentCodes.push(tipoCode);
padres = clasificador.filter(c => {
const rc = String(c.rubro).padStart(5, '0');
return parentCodes.includes(rc) && rc !== s;
});
if (hijosData) {
// Filtrar solo hijos directos (siguiente nivel)
hijos = hijosData.filter(h => {
// Hijos directos
if (nivel === 'tipo') {
// Hijos de tipo son clase: XXX00 donde XX = tipo
return h.rubro.startsWith(codigo.substring(0, 2)) && h.nivel === 'clase';
hijos = clasificador.filter(c => c.nivel === 'clase' && String(c.rubro).padStart(5, '0').substring(0, 2) === s.substring(0, 2));
} else if (nivel === 'clase') {
// Hijos de clase son cuenta: XXXX0 donde XXX = clase
return h.rubro.startsWith(codigo.substring(0, 3)) && h.nivel === 'cuenta';
hijos = clasificador.filter(c => (c.nivel === 'cuenta') && String(c.rubro).padStart(5, '0').substring(0, 3) === s.substring(0, 3));
} else if (nivel === 'cuenta') {
// Hijos de cuenta son sub_cuenta: XXXXX donde XXXX = cuenta
return h.rubro.startsWith(codigo.substring(0, 4)) && h.nivel === 'sub_cuenta';
}
return false;
});
hijos = clasificador.filter(c => (c.nivel === 'sub_cuenta') && String(c.rubro).padStart(5, '0').substring(0, 4) === s.substring(0, 4));
}
padres.sort((a, b) => String(a.rubro).localeCompare(String(b.rubro)));
hijos.sort((a, b) => String(a.rubro).localeCompare(String(b.rubro)));
}
} catch { /* optional */ }
return {
rubro,
padres,
hijos
};
return { rubro, padres, hijos };
}
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
......@@ -44,7 +44,7 @@
const CLAS_SEARCH_MAP = {
objeto: { class_: 'objeto', codigoFn: (meta) => meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '' },
finfun: { class_: 'finfun', codigoFn: (meta) => {
finfun: { class_: 'finalidad', codigoFn: (meta) => {
const fin = String(meta.finfun_finalidad || '');
if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
if (meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}`;
......@@ -123,13 +123,14 @@
resumen = clasUbigeoCache[key];
return;
}
const params = new URLSearchParams({ codigo });
if (gestion) params.set('gestion', gestion);
try {
const res = await fetch(`${apiEndpoint}?${params}`);
const data = await res.json();
const mapped = (data || [])
.filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo))
.filter(d => d.ubigeo !== '0.0.0' && !d.ubigeo.endsWith('.0') && !/multimunicipal/i.test(d.desc_ubigeo) && !/multiprovincial/i.test(d.desc_ubigeo) && !/desconocido/i.test(d.desc_ubigeo))
.map(d => ({
codigo: d.ubigeo,
desc: d.desc_ubigeo,
......@@ -403,6 +404,18 @@
const data = await res.json();
if (data?.desc_objeto) return data.desc_objeto;
}
} else if (clas === 'finfun') {
const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=clasificador`);
if (res.ok) {
const data = await res.json();
if (data?.desc_finfun) return data.desc_finfun;
}
} else if (clas === 'acteco') {
const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=clasificador`);
if (res.ok) {
const data = await res.json();
if (data?.desc_acteco) return data.desc_acteco;
}
}
// Fallback: buscar en Typesense
const cfg = CLAS_SEARCH_MAP[clas];
......@@ -482,6 +495,7 @@
<title>Gasto por geografía | Presupuesto Público</title>
</svelte:head>
{#if mapaData}
<div class="dashboard">
<div class="mapa-fullscreen">
<div class="mapa-top-controls">
......@@ -496,6 +510,11 @@
<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 === 'acteco'}
<a href="/acteco/{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}
......@@ -698,7 +717,7 @@
<span class="tooltip-rank">#{pos}/{allBarras.length}</span>
{/if}
</div>
<span class="mapa-muni-count">{barrasRankeadas.length} municipios</span>
<span class="mapa-muni-count">{barrasRankeadas.length} municipios/TIOCs</span>
</div>
</div>
......@@ -721,7 +740,7 @@
<!-- Drawer lateral de ranking -->
<div class="ranking-drawer" class:open={drawerOpen}>
<div class="drawer-header">
<span class="drawer-titulo">{barrasRankeadas.length} municipios</span>
<span class="drawer-titulo">{barrasRankeadas.length} municipios/TIOCs</span>
<button class="drawer-close" onclick={() => { drawerOpen = false; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
......@@ -769,8 +788,33 @@
</div>
</div>
</div>
{:else}
<div class="mapa-loading">
<div class="mapa-loading-inner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="animation: spin 1.5s linear infinite; opacity: 0.4;">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span style="font-family: var(--font-sans); font-size: 13px; color: var(--theme-texto); opacity: 0.5;">Cargando mapa...</span>
</div>
</div>
{/if}
<style>
.mapa-loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--theme-body);
}
.mapa-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.dashboard {
min-height: 100vh;
background: var(--theme-body);
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params, fetch }) {
const codigo = params.ubigeo;
const municipioUbigeo = params.ubigeo;
// Cargar resumen y población en paralelo
const [resumenRes, pobRes] = await Promise.all([
supabase
.schema('ppto')
.from('entidad_resumen')
.select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
.eq('codigo', codigo),
// Primero obtener el clasificador para mapear municipio_ubigeo → ubigeo con puntos
const clasRes = await fetch('/api/ubigeo-data?codigo=0&tipo=clasificador');
if (!clasRes.ok) throw error(500, 'Error cargando clasificador');
const clasificador = await clasRes.json();
const match = clasificador.find(c => String(c.municipio_ubigeo) === municipioUbigeo);
if (!match) throw error(404, 'Ubicación no encontrada');
const ubigeoCode = match.ubigeo; // formato "2.7.7"
// Cargar detalle del ubigeo + población en paralelo
const [ubigeoRes, pobRes] = await Promise.all([
fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=detalle`).then(r => r.ok ? r.json() : null),
fetch('/poblacion.csv').then(r => r.text())
]);
if (resumenRes.error || !resumenRes.data?.length) {
throw error(404, 'Ubicación no encontrada');
}
if (!ubigeoRes) throw error(404, 'Ubicación no encontrada');
// Parsear población para este código (codigo_ine)
// Población
const poblacionMap = {};
pobRes.split('\n').slice(1).forEach(line => {
const [cod, , gestion, pob] = line.split(',');
if (cod === codigo) {
if (cod === municipioUbigeo) {
poblacionMap[parseInt(gestion)] = parseInt(pob);
}
});
// Determinar última gestión
const gestiones = [...new Set(resumenRes.data.map(d => d.gestion))].sort((a, b) => b - a);
const ultimaGestion = gestiones[0] || 2025;
// gastos_ingresos ��� resumenData
const gastosIngresos = ubigeoRes.gastos_ingresos || [];
const resumenData = gastosIngresos.map(d => ({
tipo: d.tipo,
gestion: d.gestion,
devengado: d.devengado,
ranking: d.ranking,
codigo: municipioUbigeo,
desc: ubigeoRes.desc_ubigeo
}));
// Cargar distribuciones de la última gestión
const distRes = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigo)
.eq('gestion', ultimaGestion);
// Última gestión
const gestiones = [...new Set(gastosIngresos.map(d => d.gestion))].filter(g => g <= 2025).sort((a, b) => b - a);
const ultimaGestion = gestiones[0] || 2025;
const firstRow = resumenRes.data[0];
// Distribuciones de la última gestión
const distEndpoints = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' }
];
const distResults = await Promise.all(
distEndpoints.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=${api}`);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter(d => d.gestion === ultimaGestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
return {
nombre: firstRow.desc || `Ubicación ${codigo}`,
nombrePadre: firstRow.desc_padre || null,
codigo,
resumenData: resumenRes.data || [],
distribucionesData: distRes.data || [],
nombre: ubigeoRes.desc_ubigeo || `Ubicación ${municipioUbigeo}`,
nombrePadre: ubigeoRes.desc_departamento || null,
codigo: municipioUbigeo,
ubigeoCode,
resumenData,
distribucionesData: distResults.flat(),
gestionInicial: ultimaGestion,
poblacionMap
};
......
......@@ -3,7 +3,6 @@
import { page } from '$app/stores';
import { get } from 'svelte/store';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
import { mapaCache } from '$lib/stores/mapaCache';
let { data } = $props();
......@@ -107,15 +106,41 @@
return;
}
cargandoDist = true;
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigoSeleccionado)
.eq('gestion', gestion);
const result = rows || [];
const ubigeoCode = data.ubigeoCode;
const dims = [
{ api: 'objetos', dim: 'objeto' },
{ api: 'finfuns', dim: 'finfun' },
{ api: 'actecos', dim: 'acteco' }
];
try {
const results = await Promise.all(
dims.map(async ({ api, dim }) => {
try {
const res = await fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=${api}`);
if (!res.ok) return [];
const rows = await res.json();
if (!Array.isArray(rows)) return [];
return rows
.filter(d => d.gestion === gestion)
.map(d => ({
tipo: d.tipo || 'gastos',
dimension: dim,
gestion: d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: d.devengado
}));
} catch { return []; }
})
);
const result = results.flat();
distCache[gestion] = result;
distribucionesData = result;
} catch {
distribucionesData = [];
}
cargandoDist = false;
}
......
This diff could not be displayed because it is too large.