Rafael Lopez

docs

...@@ -114,7 +114,7 @@ ...@@ -114,7 +114,7 @@
114 } else if (!isClass) { 114 } else if (!isClass) {
115 // Programa/proyecto 115 // Programa/proyecto
116 tipo = 'programa'; 116 tipo = 'programa';
117 - codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`; 117 + codigo = String(meta.programa || '');
118 } 118 }
119 119
120 // Contexto extra para programas 120 // Contexto extra para programas
...@@ -213,7 +213,7 @@ ...@@ -213,7 +213,7 @@
213 rubro: '/rubro/', 213 rubro: '/rubro/',
214 organismo: '/organismo/', 214 organismo: '/organismo/',
215 ubigeo: '/ubicacion/', 215 ubigeo: '/ubicacion/',
216 - programa: '/proyecto/' 216 + programa: '/programa/'
217 }; 217 };
218 const base = routes[item.tipo]; 218 const base = routes[item.tipo];
219 if (base) { 219 if (base) {
......
...@@ -807,7 +807,7 @@ ...@@ -807,7 +807,7 @@
807 } else if (isClassResult) { 807 } else if (isClassResult) {
808 url = `/clasificador/${hit.document.class_}/${hit.document.id}`; 808 url = `/clasificador/${hit.document.class_}/${hit.document.id}`;
809 } else { 809 } else {
810 - url = `/proyecto/${generateCodigo(hit)}`; 810 + url = `/programa/${generateCodigo(hit)}`;
811 } 811 }
812 812
813 window.location.href = url; 813 window.location.href = url;
...@@ -921,7 +921,7 @@ ...@@ -921,7 +921,7 @@
921 921
922 function generateCodigo(hit) { 922 function generateCodigo(hit) {
923 const m = parseMetadatos(hit.document.metadatos); 923 const m = parseMetadatos(hit.document.metadatos);
924 - return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`; 924 + return String(m.programa || '');
925 } 925 }
926 926
927 $: stats = filteredHits.length > 0 ? getStats( 927 $: stats = filteredHits.length > 0 ? getStats(
...@@ -1485,7 +1485,7 @@ ...@@ -1485,7 +1485,7 @@
1485 ? `/rubro/${rubroCode}` 1485 ? `/rubro/${rubroCode}`
1486 : isOrganismoClass 1486 : isOrganismoClass
1487 ? `/organismo/${meta.organismo || ''}` 1487 ? `/organismo/${meta.organismo || ''}`
1488 - : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)} 1488 + : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/programa/${generateCodigo(hit)}`)}
1489 class="result-card" 1489 class="result-card"
1490 class:result-card-selected={selectedResultIndex === i} 1490 class:result-card-selected={selectedResultIndex === i}
1491 data-result-index={i} 1491 data-result-index={i}
......
1 +const API_BASE = 'http://136.112.29.74/api/diario';
2 +
3 +export async function GET({ url }) {
4 + const tipo = url.searchParams.get('tipo') || 'status';
5 +
6 + const endpoints = {
7 + status: `${API_BASE}/status`,
8 + timeseries: `${API_BASE}/timeseries`,
9 + entidades: `${API_BASE}/entidades`,
10 + clasificadores: `${API_BASE}/clasificadores`,
11 + timeline: `${API_BASE}/timeline`
12 + };
13 +
14 + const apiUrl = endpoints[tipo];
15 + if (!apiUrl) {
16 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
17 + status: 400, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +
21 + try {
22 + const response = await fetch(apiUrl);
23 + if (!response.ok) {
24 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
25 + status: response.status, headers: { 'Content-Type': 'application/json' }
26 + });
27 + }
28 + const data = await response.json();
29 + return new Response(JSON.stringify(data), {
30 + headers: { 'Content-Type': 'application/json' }
31 + });
32 + } catch {
33 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
34 + status: 500, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 +}
1 +const API_BASE = 'http://136.112.29.74/api/programa_proyecto';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'detalle'; // detalle, proyectos
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400, headers: { 'Content-Type': 'application/json' }
10 + });
11 + }
12 +
13 + const apiUrl = tipo === 'proyectos'
14 + ? `${API_BASE}/${codigo}/proyectos`
15 + : `${API_BASE}/${codigo}`;
16 +
17 + try {
18 + const response = await fetch(apiUrl);
19 + if (!response.ok) {
20 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
21 + status: response.status, headers: { 'Content-Type': 'application/json' }
22 + });
23 + }
24 + const data = await response.json();
25 + return new Response(JSON.stringify(data), {
26 + headers: { 'Content-Type': 'application/json' }
27 + });
28 + } catch {
29 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
30 + status: 500, headers: { 'Content-Type': 'application/json' }
31 + });
32 + }
33 +}
...@@ -11,8 +11,11 @@ ...@@ -11,8 +11,11 @@
11 let searchableItems = $state([]); 11 let searchableItems = $state([]);
12 let isMac = $state(false); 12 let isMac = $state(false);
13 13
14 - // Markdown content embedded directly 14 + // Markdown content loaded from static file
15 - const MARKDOWN_CONTENT = `# Introducción 15 + let MARKDOWN_CONTENT = $state('');
16 +
17 + // Placeholder for initial content
18 + const INITIAL_CONTENT = `# Introducción
16 19
17 El presupuesto público se presenta en dos distribuciones de datos: una histórica y otra en tiempo real. 20 El presupuesto público se presenta en dos distribuciones de datos: una histórica y otra en tiempo real.
18 21
...@@ -1043,6 +1046,12 @@ Ejemplo: \`5637823.65\`. ...@@ -1043,6 +1046,12 @@ Ejemplo: \`5637823.65\`.
1043 isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform); 1046 isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
1044 1047
1045 try { 1048 try {
1049 + // Cargar markdown desde archivo estático
1050 + const res = await fetch('/docs.md');
1051 + if (res.ok) {
1052 + MARKDOWN_CONTENT = await res.text();
1053 + }
1054 +
1046 const result = processMarkdown(MARKDOWN_CONTENT); 1055 const result = processMarkdown(MARKDOWN_CONTENT);
1047 content = result.html; 1056 content = result.html;
1048 headings = buildHierarchy(result.headings); 1057 headings = buildHierarchy(result.headings);
...@@ -1327,12 +1336,6 @@ Ejemplo: \`5637823.65\`. ...@@ -1327,12 +1336,6 @@ Ejemplo: \`5637823.65\`.
1327 <header class="docs-header"> 1336 <header class="docs-header">
1328 <div class="docs-header-content"> 1337 <div class="docs-header-content">
1329 <div class="docs-header-top"> 1338 <div class="docs-header-top">
1330 - <a href="/" class="docs-back">
1331 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1332 - <path d="M19 12H5M12 19l-7-7 7-7"/>
1333 - </svg>
1334 - <span>Inicio</span>
1335 - </a>
1336 1339
1337 <!-- Mobile menu button --> 1340 <!-- Mobile menu button -->
1338 <button class="docs-menu-btn" onclick={toggleSidebar}> 1341 <button class="docs-menu-btn" onclick={toggleSidebar}>
...@@ -1501,7 +1504,7 @@ Ejemplo: \`5637823.65\`. ...@@ -1501,7 +1504,7 @@ Ejemplo: \`5637823.65\`.
1501 .docs-header-content { 1504 .docs-header-content {
1502 max-width: 1000px; 1505 max-width: 1000px;
1503 margin: 0 auto; 1506 margin: 0 auto;
1504 - padding: 1rem 1.5rem; 1507 + padding: 80px 1.5rem 1rem;
1505 } 1508 }
1506 1509
1507 .docs-header-top { 1510 .docs-header-top {
...@@ -1568,7 +1571,7 @@ Ejemplo: \`5637823.65\`. ...@@ -1568,7 +1571,7 @@ Ejemplo: \`5637823.65\`.
1568 .docs-layout-wrapper { 1571 .docs-layout-wrapper {
1569 max-width: 700px; 1572 max-width: 700px;
1570 margin: 0 auto; 1573 margin: 0 auto;
1571 - padding-top: 140px; 1574 + padding-top: 340px;
1572 padding-left: 1.5rem; 1575 padding-left: 1.5rem;
1573 padding-right: 1.5rem; 1576 padding-right: 1.5rem;
1574 } 1577 }
...@@ -1603,7 +1606,7 @@ Ejemplo: \`5637823.65\`. ...@@ -1603,7 +1606,7 @@ Ejemplo: \`5637823.65\`.
1603 1606
1604 .docs-sidebar-content { 1607 .docs-sidebar-content {
1605 padding-right: 1rem; 1608 padding-right: 1rem;
1606 - padding-top: 2rem; 1609 + padding-top: 10rem;
1607 } 1610 }
1608 } 1611 }
1609 1612
...@@ -1824,7 +1827,7 @@ Ejemplo: \`5637823.65\`. ...@@ -1824,7 +1827,7 @@ Ejemplo: \`5637823.65\`.
1824 1827
1825 @media (max-width: 1099px) { 1828 @media (max-width: 1099px) {
1826 .docs-layout-wrapper { 1829 .docs-layout-wrapper {
1827 - padding-top: 130px; 1830 + padding-top: 330px;
1828 padding-left: 1rem; 1831 padding-left: 1rem;
1829 padding-right: 1rem; 1832 padding-right: 1rem;
1830 } 1833 }
...@@ -2049,6 +2052,7 @@ Ejemplo: \`5637823.65\`. ...@@ -2049,6 +2052,7 @@ Ejemplo: \`5637823.65\`.
2049 .docs-search-container { 2052 .docs-search-container {
2050 position: relative; 2053 position: relative;
2051 margin-top: 1rem; 2054 margin-top: 1rem;
2055 + max-width: 320px;
2052 } 2056 }
2053 2057
2054 .docs-search-input-wrapper { 2058 .docs-search-input-wrapper {
......
1 +<script>
2 + import { page } from '$app/stores';
3 + import { goto } from '$app/navigation';
4 + import { onMount } from 'svelte';
5 + import { tweened } from 'svelte/motion';
6 + import { cubicOut } from 'svelte/easing';
7 + import BarChart from '$lib/components/objeto/BarChart.svelte';
8 +
9 + let codigo = $derived($page.params.codigo);
10 +
11 + let mounted = $state(false);
12 + let loading = $state(true);
13 + let linkCopied = $state(false);
14 + let programaData = $state([]);
15 + let proyectos = $state([]);
16 + let hoveredYear = $state(null);
17 + let selectedTab = $state('objetos'); // objetos, finfun, acteco, entidades
18 +
19 + // Serie temporal
20 + let datosAnuales = $derived(programaData.filter(d => d.gestion >= 2016).sort((a, b) => a.gestion - b.gestion));
21 + let nombre = $derived(datosAnuales[0]?.desc || `Programa ${codigo}`);
22 + let nombrePadre = $derived(datosAnuales[0]?.desc_padre || '');
23 +
24 + // Datos para el gráfico
25 + let gastoPerCapita = $derived(
26 + datosAnuales.map(d => ({
27 + año: d.gestion,
28 + monto: d.devengado,
29 + perCapita: d.devengado
30 + }))
31 + );
32 +
33 + let primerAño = $derived(datosAnuales.length > 0 ? datosAnuales[0].gestion : 2016);
34 + let ultimoAño = $derived(datosAnuales.length > 0 ? datosAnuales[datosAnuales.length - 1].gestion : 2025);
35 +
36 + // Año activo (hover o último)
37 + let currentAnual = $derived(hoveredYear ? datosAnuales.find(d => d.gestion === hoveredYear) : datosAnuales[datosAnuales.length - 1]);
38 + let displayMonto = $derived(currentAnual?.devengado ?? 0);
39 + let displayEntidades = $derived(currentAnual?.n_entidad ?? 0);
40 + let displayObjetos = $derived(currentAnual?.n_objeto ?? 0);
41 + let displayPeriodo = $derived(hoveredYear ? hoveredYear : `${primerAño}-${ultimoAño}`);
42 +
43 + // Distribución según tab seleccionada
44 + let distribucion = $derived.by(() => {
45 + if (!currentAnual) return [];
46 + const items = currentAnual[selectedTab === 'entidades' ? 'entidad' : selectedTab === 'finfun' ? 'finfun' : selectedTab === 'acteco' ? 'acteco' : 'objeto'] || [];
47 + return [...items].filter(d => d.devengado > 0).sort((a, b) => b.devengado - a.devengado);
48 + });
49 +
50 + let totalDistribucion = $derived(distribucion.reduce((s, d) => s + d.devengado, 0));
51 +
52 + // Tweens
53 + const tw = { duration: 300, easing: cubicOut };
54 + const twMonto = tweened(0, tw);
55 + $effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
56 +
57 + function formatMonto(v) {
58 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
59 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
60 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
61 + return v?.toLocaleString('es-BO') || '0';
62 + }
63 +
64 + async function loadData() {
65 + loading = true;
66 + try {
67 + const [detRes, proRes] = await Promise.all([
68 + fetch(`/api/programa?codigo=${codigo}&tipo=detalle`),
69 + fetch(`/api/programa?codigo=${codigo}&tipo=proyectos`)
70 + ]);
71 + const det = await detRes.json();
72 + const pro = await proRes.json();
73 + if (Array.isArray(det)) programaData = det;
74 + if (Array.isArray(pro)) proyectos = pro.sort((a, b) => b.devengado - a.devengado);
75 + } catch (err) {
76 + console.error('[Programa] Error:', err);
77 + }
78 + loading = false;
79 + }
80 +
81 + onMount(async () => {
82 + mounted = true;
83 + await loadData();
84 + });
85 +
86 + const TABS = [
87 + { id: 'objetos', label: 'En qué se gasta' },
88 + { id: 'finfun', label: 'Para qué función' },
89 + { id: 'acteco', label: 'Sector económico' },
90 + { id: 'entidades', label: 'Quién ejecuta' }
91 + ];
92 +</script>
93 +
94 +<svelte:head>
95 + <title>{nombre} | Programa</title>
96 + <link rel="preconnect" href="https://fonts.googleapis.com" />
97 + <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" />
98 +</svelte:head>
99 +
100 +<div class="page" class:mounted>
101 + <div class="layout">
102 + <!-- Header -->
103 + <header class="header">
104 + <div class="header-top">
105 + <div class="pills">
106 + <span class="pill pill-tipo">Programa</span>
107 + <span class="pill pill-codigo">{codigo}</span>
108 + </div>
109 + <div class="header-actions">
110 + <button class="share-btn" onclick={() => {
111 + navigator.clipboard.writeText(window.location.href);
112 + linkCopied = true;
113 + setTimeout(() => linkCopied = false, 2000);
114 + }}>
115 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
116 + {#if linkCopied}
117 + <path d="M20 6L9 17l-5-5"/>
118 + {:else}
119 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
120 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
121 + {/if}
122 + </svg>
123 + {linkCopied ? 'Copiado' : 'Compartir'}
124 + </button>
125 + <a href="/" class="back-link">
126 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
127 + <path d="M19 12H5M12 19l-7-7 7-7"/>
128 + </svg>
129 + Volver
130 + </a>
131 + </div>
132 + </div>
133 +
134 + <h1 class="title">{nombre}</h1>
135 + {#if nombrePadre}
136 + <p class="subtitle">{nombrePadre}</p>
137 + {/if}
138 + </header>
139 +
140 + {#if loading}
141 + <div class="loading">Cargando...</div>
142 + {:else}
143 + <!-- Main content -->
144 + <div class="content">
145 + <!-- Gráfico + KPIs -->
146 + <div class="chart-card">
147 + <div class="chart-header">
148 + <span class="chart-label">Bs ejecutados <span class="chart-period">· {displayPeriodo}</span></span>
149 + </div>
150 + <div class="chart-wrapper">
151 + <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
152 + </div>
153 + <div class="kpis">
154 + <div class="kpi">
155 + <span class="kpi-value">{formatMonto(Math.round($twMonto))}</span>
156 + <span class="kpi-label">{hoveredYear ? `ejecutados en ${hoveredYear}` : `ejecutados ${primerAño}-${ultimoAño}`}</span>
157 + </div>
158 + <div class="kpi">
159 + <span class="kpi-value">{displayEntidades}</span>
160 + <span class="kpi-label">entidades</span>
161 + </div>
162 + <div class="kpi">
163 + <span class="kpi-value">{displayObjetos}</span>
164 + <span class="kpi-label">partidas de gasto</span>
165 + </div>
166 + </div>
167 + </div>
168 +
169 + <!-- Distribución -->
170 + <div class="dist-card">
171 + <div class="tabs">
172 + {#each TABS as tab}
173 + <button class="tab" class:active={selectedTab === tab.id} onclick={() => selectedTab = tab.id}>
174 + {tab.label}
175 + </button>
176 + {/each}
177 + </div>
178 +
179 + <div class="dist-list">
180 + {#each distribucion as item, i}
181 + {@const pct = totalDistribucion > 0 ? (item.devengado / totalDistribucion * 100) : 0}
182 + <div class="dist-item">
183 + <div class="dist-bar" style="width: {pct}%"></div>
184 + <span class="dist-rank">#{i + 1}</span>
185 + <span class="dist-name">{item.desc || `Código ${item.codigo}`}</span>
186 + <span class="dist-pct">{pct.toFixed(1)}%</span>
187 + </div>
188 + {/each}
189 + {#if distribucion.length === 0}
190 + <p class="dist-empty">Sin datos para este clasificador</p>
191 + {/if}
192 + </div>
193 + </div>
194 +
195 + <!-- Proyectos/Actividades -->
196 + {#if proyectos.length > 0}
197 + <div class="proyectos-card">
198 + <h3 class="card-title">Actividades y proyectos ({proyectos.length})</h3>
199 + <div class="proyectos-list">
200 + {#each proyectos as proy, i}
201 + {@const pct = proyectos[0]?.devengado > 0 ? (proy.devengado / proyectos[0].devengado * 100) : 0}
202 + <div class="proy-item">
203 + <span class="proy-rank">#{i + 1}</span>
204 + <div class="proy-info">
205 + <span class="proy-name">{proy.desc}</span>
206 + <span class="proy-code">{proy.codigo}</span>
207 + </div>
208 + <span class="proy-monto">{formatMonto(proy.devengado)} Bs</span>
209 + </div>
210 + {/each}
211 + </div>
212 + </div>
213 + {/if}
214 + </div>
215 + {/if}
216 + </div>
217 +</div>
218 +
219 +<style>
220 + .page {
221 + min-height: 100vh;
222 + width: 100%;
223 + overflow-x: hidden;
224 + background: var(--theme-body);
225 + color: var(--theme-titulo);
226 + font-family: var(--font-sans), -apple-system, sans-serif;
227 + opacity: 0;
228 + transition: opacity 0.4s;
229 + }
230 + .page.mounted { opacity: 1; }
231 + :global(html:not(.dark)) .page { background: #f5f5f7; }
232 +
233 + .layout {
234 + max-width: 1200px;
235 + margin: 0 auto;
236 + padding: 80px 2rem 2rem;
237 + }
238 +
239 + /* Header */
240 + .header { margin-bottom: 1.5rem; }
241 +
242 + .header-top {
243 + display: flex;
244 + justify-content: space-between;
245 + align-items: center;
246 + margin-bottom: 0.375rem;
247 + }
248 +
249 + .pills { display: flex; gap: 0.375rem; }
250 + .pill {
251 + font-family: 'DM Mono', monospace;
252 + font-size: 0.625rem;
253 + padding: 0.125rem 0.5rem;
254 + border-radius: 4px;
255 + letter-spacing: 0.03em;
256 + }
257 + .pill-tipo { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
258 + .pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
259 +
260 + .header-actions { display: flex; align-items: center; gap: 0.75rem; }
261 +
262 + .share-btn, .back-link {
263 + display: flex;
264 + align-items: center;
265 + gap: 0.25rem;
266 + font-size: 0.6875rem;
267 + color: var(--theme-texto);
268 + opacity: 0.6;
269 + background: none;
270 + border: none;
271 + padding: 0;
272 + cursor: pointer;
273 + text-decoration: none;
274 + transition: opacity 0.15s;
275 + }
276 + .share-btn:hover, .back-link:hover { opacity: 1; }
277 + .back-link { color: var(--theme-accent); opacity: 1; }
278 +
279 + .title {
280 + font-family: 'DM Serif Display', serif;
281 + font-size: 2rem;
282 + font-weight: 400;
283 + margin: 0;
284 + line-height: 1.15;
285 + }
286 +
287 + .subtitle {
288 + font-size: 0.8125rem;
289 + color: var(--theme-texto);
290 + opacity: 0.6;
291 + margin: 0.25rem 0 0;
292 + }
293 +
294 + .loading {
295 + display: flex;
296 + align-items: center;
297 + justify-content: center;
298 + height: 50vh;
299 + color: var(--theme-texto);
300 + opacity: 0.5;
301 + }
302 +
303 + /* Content */
304 + .content {
305 + display: flex;
306 + flex-direction: column;
307 + gap: 1rem;
308 + }
309 +
310 + /* Chart card */
311 + .chart-card {
312 + background: var(--theme-surface);
313 + border-radius: 12px;
314 + padding: 1rem;
315 + }
316 +
317 + .chart-header { margin-bottom: 0.5rem; }
318 + .chart-label {
319 + font-family: 'DM Mono', monospace;
320 + font-size: 0.6875rem;
321 + color: var(--theme-texto);
322 + opacity: 0.7;
323 + }
324 + .chart-period { opacity: 0.5; }
325 +
326 + .chart-wrapper { height: 200px; }
327 +
328 + .kpis {
329 + display: grid;
330 + grid-template-columns: repeat(3, 1fr);
331 + gap: 0.5rem;
332 + margin-top: 0.75rem;
333 + padding-top: 0.75rem;
334 + border-top: 1px solid var(--theme-borde);
335 + }
336 +
337 + .kpi { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.125rem; }
338 + .kpi-value { font-family: 'Qanelas', var(--font-sans); font-size: 1rem; font-weight: 600; color: var(--theme-titulo); }
339 + .kpi-label { font-size: 0.625rem; color: var(--theme-texto); opacity: 0.5; }
340 +
341 + /* Distribution */
342 + .dist-card {
343 + background: var(--theme-surface);
344 + border-radius: 12px;
345 + padding: 1rem;
346 + }
347 +
348 + .tabs {
349 + display: flex;
350 + gap: 0.25rem;
351 + margin-bottom: 0.75rem;
352 + overflow-x: auto;
353 + scrollbar-width: none;
354 + }
355 + .tabs::-webkit-scrollbar { display: none; }
356 +
357 + .tab {
358 + font-size: 0.6875rem;
359 + font-weight: 500;
360 + padding: 0.375rem 0.75rem;
361 + border: 1px solid var(--theme-borde);
362 + border-radius: 6px;
363 + background: transparent;
364 + color: var(--theme-texto);
365 + cursor: pointer;
366 + white-space: nowrap;
367 + transition: all 0.15s;
368 + }
369 + .tab:hover { border-color: var(--theme-accent); }
370 + .tab.active {
371 + background: var(--theme-accent);
372 + border-color: var(--theme-accent);
373 + color: white;
374 + font-weight: 600;
375 + }
376 +
377 + .dist-list { display: flex; flex-direction: column; gap: 0.25rem; }
378 +
379 + .dist-item {
380 + position: relative;
381 + display: flex;
382 + align-items: center;
383 + gap: 0.5rem;
384 + padding: 0.375rem 0.5rem;
385 + border-radius: 6px;
386 + overflow: hidden;
387 + }
388 +
389 + .dist-bar {
390 + position: absolute;
391 + left: 0;
392 + top: 0;
393 + bottom: 0;
394 + background: rgba(201, 167, 81, 0.1);
395 + border-radius: 6px;
396 + }
397 +
398 + .dist-rank {
399 + position: relative;
400 + font-family: 'DM Mono', monospace;
401 + font-size: 0.625rem;
402 + color: var(--theme-accent);
403 + font-weight: 600;
404 + min-width: 1.5rem;
405 + }
406 +
407 + .dist-name {
408 + position: relative;
409 + flex: 1;
410 + font-size: 0.75rem;
411 + color: var(--theme-titulo);
412 + overflow: hidden;
413 + text-overflow: ellipsis;
414 + white-space: nowrap;
415 + }
416 +
417 + .dist-pct {
418 + position: relative;
419 + font-family: 'DM Mono', monospace;
420 + font-size: 0.6875rem;
421 + color: var(--theme-texto);
422 + opacity: 0.7;
423 + }
424 +
425 + .dist-empty {
426 + font-size: 0.8125rem;
427 + color: var(--theme-texto);
428 + opacity: 0.5;
429 + text-align: center;
430 + padding: 1rem;
431 + }
432 +
433 + /* Proyectos */
434 + .proyectos-card {
435 + background: var(--theme-surface);
436 + border-radius: 12px;
437 + padding: 1rem;
438 + }
439 +
440 + .card-title {
441 + font-size: 0.875rem;
442 + font-weight: 700;
443 + margin: 0 0 0.75rem;
444 + }
445 +
446 + .proyectos-list { display: flex; flex-direction: column; gap: 0.375rem; }
447 +
448 + .proy-item {
449 + display: flex;
450 + align-items: center;
451 + gap: 0.5rem;
452 + padding: 0.375rem 0;
453 + border-bottom: 1px dotted rgba(128, 128, 128, 0.2);
454 + }
455 + .proy-item:last-child { border-bottom: none; }
456 +
457 + .proy-rank {
458 + font-family: 'DM Mono', monospace;
459 + font-size: 0.625rem;
460 + color: var(--theme-accent);
461 + font-weight: 600;
462 + min-width: 1.5rem;
463 + }
464 +
465 + .proy-info { flex: 1; min-width: 0; }
466 + .proy-name { font-size: 0.75rem; color: var(--theme-titulo); display: block; }
467 + .proy-code {
468 + font-family: 'DM Mono', monospace;
469 + font-size: 0.5625rem;
470 + color: var(--theme-texto);
471 + opacity: 0.5;
472 + }
473 +
474 + .proy-monto {
475 + font-family: 'DM Mono', monospace;
476 + font-size: 0.6875rem;
477 + color: var(--theme-titulo);
478 + white-space: nowrap;
479 + }
480 +
481 + /* Responsive */
482 + @media (max-width: 640px) {
483 + .layout { padding: 70px 1rem 1rem; }
484 + .title { font-size: 1.5rem; }
485 + .kpis { grid-template-columns: repeat(3, 1fr); }
486 + .header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
487 + }
488 +</style>
...@@ -35,18 +35,24 @@ ...@@ -35,18 +35,24 @@
35 } 35 }
36 36
37 async function cargarMapa(codigo) { 37 async function cargarMapa(codigo) {
38 + console.time('[MAPA] total');
38 const cached = get(mapaCache); 39 const cached = get(mapaCache);
39 if (cached) { 40 if (cached) {
41 + console.log('[MAPA] usando cache');
40 mapaData = cached; 42 mapaData = cached;
41 } else if (!mapaData) { 43 } else if (!mapaData) {
44 + console.time('[MAPA] fetch');
42 const res = await fetch('/mapa.json'); 45 const res = await fetch('/mapa.json');
43 mapaData = await res.json(); 46 mapaData = await res.json();
47 + console.timeEnd('[MAPA] fetch');
44 delete mapaData.bolivia.crs; 48 delete mapaData.bolivia.crs;
45 delete mapaData.departamentos.crs; 49 delete mapaData.departamentos.crs;
46 delete mapaData.municipios.crs; 50 delete mapaData.municipios.crs;
51 + console.time('[MAPA] fixWinding');
47 fixWinding(mapaData.bolivia); 52 fixWinding(mapaData.bolivia);
48 fixWinding(mapaData.departamentos); 53 fixWinding(mapaData.departamentos);
49 fixWinding(mapaData.municipios); 54 fixWinding(mapaData.municipios);
55 + console.timeEnd('[MAPA] fixWinding');
50 mapaCache.set(mapaData); 56 mapaCache.set(mapaData);
51 } 57 }
52 58
...@@ -73,11 +79,14 @@ ...@@ -73,11 +79,14 @@
73 mapaMunicipioPath = pathGen(municipio) || ''; 79 mapaMunicipioPath = pathGen(municipio) || '';
74 80
75 // Pre-calcular paths de todos los municipios para hover 81 // Pre-calcular paths de todos los municipios para hover
82 + console.time('[MAPA] allMunisPaths');
76 mapaAllMunisPaths = mapaData.municipios.features.map(f => ({ 83 mapaAllMunisPaths = mapaData.municipios.features.map(f => ({
77 path: pathGen(f) || '', 84 path: pathGen(f) || '',
78 codigo: f.properties.codigo, 85 codigo: f.properties.codigo,
79 isCurrent: f.properties.codigo === codigoNum 86 isCurrent: f.properties.codigo === codigoNum
80 })); 87 }));
88 + console.timeEnd('[MAPA] allMunisPaths');
89 + console.timeEnd('[MAPA] total');
81 } 90 }
82 91
83 // Datos desde el loader (Supabase) 92 // Datos desde el loader (Supabase)
......
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 - import { parquetReadObjects, asyncBufferFromUrl } from 'hyparquet';
4 import * as Plot from '@observablehq/plot'; 3 import * as Plot from '@observablehq/plot';
5 import * as d3 from 'd3'; 4 import * as d3 from 'd3';
6 5
...@@ -124,10 +123,9 @@ ...@@ -124,10 +123,9 @@
124 // Navegación interna entre secciones 123 // Navegación interna entre secciones
125 let seccionActiva = $state(0); 124 let seccionActiva = $state(0);
126 const secciones = [ 125 const secciones = [
127 - { id: 'clasificadores', titulo: '¿En qué se gasta?' },
128 - { id: 'entidades', titulo: '¿Quién ejecuta más?' },
129 { id: 'programas', titulo: '¿En qué programas?' }, 126 { id: 'programas', titulo: '¿En qué programas?' },
130 - { id: 'evolucion', titulo: '¿Cómo evoluciona?' } 127 + { id: 'clasificadores', titulo: '¿En qué se gasta?' },
128 + { id: 'entidades', titulo: '¿Quién ejecuta más?' }
131 ]; 129 ];
132 130
133 function irASeccion(index) { 131 function irASeccion(index) {
...@@ -219,11 +217,11 @@ ...@@ -219,11 +217,11 @@
219 }; 217 };
220 } 218 }
221 219
222 - // Cargar archivo Parquet usando asyncBufferFromUrl 220 + // Cargar datos desde API
223 - async function cargarParquet(url) { 221 + async function cargarDiario(tipo) {
224 - const file = await asyncBufferFromUrl({ url }); 222 + const res = await fetch(`/api/diario?tipo=${tipo}`);
225 - const data = await parquetReadObjects({ file }); 223 + if (!res.ok) throw new Error(`Error ${res.status}`);
226 - return data; 224 + return await res.json();
227 } 225 }
228 226
229 onMount(async () => { 227 onMount(async () => {
...@@ -251,12 +249,12 @@ ...@@ -251,12 +249,12 @@
251 themeObserver.observe(document.documentElement, { attributes: true }); 249 themeObserver.observe(document.documentElement, { attributes: true });
252 250
253 try { 251 try {
254 - // Cargar todos los parquets en paralelo 252 + // Cargar todos los datos en paralelo desde API
255 const [tsData, entData, clasData, tlData] = await Promise.all([ 253 const [tsData, entData, clasData, tlData] = await Promise.all([
256 - cargarParquet('/timeserie@4.parquet'), 254 + cargarDiario('timeseries'),
257 - cargarParquet('/ejecucion_entidades@1.parquet'), 255 + cargarDiario('entidades'),
258 - cargarParquet('/ejecucion_clasificadores@3.parquet'), 256 + cargarDiario('clasificadores'),
259 - cargarParquet('/timeline@1.parquet') 257 + cargarDiario('timeline')
260 ]); 258 ]);
261 259
262 // Los datos ya vienen como objetos 260 // Los datos ya vienen como objetos
...@@ -282,12 +280,12 @@ ...@@ -282,12 +280,12 @@
282 280
283 // Filtrar datos por transferencias y días 281 // Filtrar datos por transferencias y días
284 let timeserieFiltered = $derived( 282 let timeserieFiltered = $derived(
285 - timeserie.filter(d => d.transferencias === incluirTransferencias) 283 + timeserie.filter(d => d.transferencias === undefined || d.transferencias === incluirTransferencias)
286 ); 284 );
287 285
288 // Datos por clasificador para las barras apiladas (sin agrupar, cada hijo es un segmento) 286 // Datos por clasificador para las barras apiladas (sin agrupar, cada hijo es un segmento)
289 let datosPorClasificador = $derived.by(() => { 287 let datosPorClasificador = $derived.by(() => {
290 - const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados; 288 + const dias = diasSeleccionados === 'total' ? 0 : parseInt(diasSeleccionados);
291 const result = {}; 289 const result = {};
292 290
293 clasificadores.forEach(({ key }) => { 291 clasificadores.forEach(({ key }) => {
...@@ -447,7 +445,7 @@ ...@@ -447,7 +445,7 @@
447 445
448 // Todas las entidades filtradas (sin límite) 446 // Todas las entidades filtradas (sin límite)
449 let entidadesTodas = $derived.by(() => { 447 let entidadesTodas = $derived.by(() => {
450 - const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados; 448 + const dias = diasSeleccionados === 'total' ? 0 : parseInt(diasSeleccionados);
451 let filtered = ejecucionEntidades.filter(d => 449 let filtered = ejecucionEntidades.filter(d =>
452 d.transferencias === incluirTransferencias && 450 d.transferencias === incluirTransferencias &&
453 String(d.dias) === String(dias) && 451 String(d.dias) === String(dias) &&
...@@ -975,7 +973,71 @@ ...@@ -975,7 +973,71 @@
975 <p>Error: {error}</p> 973 <p>Error: {error}</p>
976 </div> 974 </div>
977 {:else} 975 {:else}
978 - <!-- SECCIÓN 1: Clasificadores --> 976 + <!-- SECCIÓN 1: Programas (timeline) -->
977 + <section class="vista-seccion" id="programas">
978 + <div class="vista-contenido">
979 + <div class="contenido-centrado">
980 + <header class="seccion-header seccion-header-centrado">
981 + <h1 class="seccion-titulo-grande">¿En qué programas?</h1>
982 + <p class="seccion-descripcion">Detalle de programas y actividades ejecutadas en la última semana</p>
983 + </header>
984 +
985 + <div class="timeline-search">
986 + <svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
987 + <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
988 + </svg>
989 + <input
990 + type="text"
991 + placeholder="Buscar entidad..."
992 + bind:value={busquedaTimeline}
993 + class="timeline-search-input"
994 + />
995 + </div>
996 +
997 + <div class="timeline-cards">
998 + {#each timelineByEntity as entidad}
999 + <div class="timeline-card">
1000 + <div class="timeline-card-header">
1001 + <div class="timeline-card-entity">
1002 + <span class="timeline-card-area">{entidad.entidad_desc_area}</span>
1003 + <span class="timeline-card-nombre">{entidad.entidad_desc_entidad}</span>
1004 + </div>
1005 + <div class="timeline-card-total">
1006 + <span class="timeline-card-total-label">Ejecutado</span>
1007 + <span class="timeline-card-total-monto">Bs. {formatearNumeroCompleto(entidad.total)}</span>
1008 + </div>
1009 + </div>
1010 + <div class="timeline-card-body">
1011 + {#each cardsExpandidas[entidad.entidad] ? entidad.programas : entidad.programas.slice(0, 8) as prog}
1012 + <div class="timeline-prog-row">
1013 + <div class="timeline-prog-info">
1014 + <span class="timeline-prog-programa">{prog.programa}</span>
1015 + <span class="timeline-prog-actividad">{prog.actividad}</span>
1016 + </div>
1017 + <span class="timeline-prog-monto">Bs. {formatearNumeroCompleto(prog.devengado)}</span>
1018 + </div>
1019 + {/each}
1020 +
1021 + {#if entidad.programas.length > 8}
1022 + <button
1023 + class="timeline-ver-mas"
1024 + onclick={() => {
1025 + cardsExpandidas[entidad.entidad] = !cardsExpandidas[entidad.entidad];
1026 + cardsExpandidas = { ...cardsExpandidas };
1027 + }}
1028 + >
1029 + {cardsExpandidas[entidad.entidad] ? 'Ver menos' : `Ver ${entidad.programas.length - 8} más...`}
1030 + </button>
1031 + {/if}
1032 + </div>
1033 + </div>
1034 + {/each}
1035 + </div>
1036 + </div>
1037 + </div>
1038 + </section>
1039 +
1040 + <!-- SECCIÓN 2: Clasificadores -->
979 <section class="vista-seccion" id="clasificadores"> 1041 <section class="vista-seccion" id="clasificadores">
980 <div class="vista-contenido"> 1042 <div class="vista-contenido">
981 <div class="contenido-centrado"> 1043 <div class="contenido-centrado">
...@@ -1143,73 +1205,9 @@ ...@@ -1143,73 +1205,9 @@
1143 </div> 1205 </div>
1144 </section> 1206 </section>
1145 1207
1146 - <!-- SECCIÓN 3: Programas -->
1147 - <section class="vista-seccion" id="programas">
1148 - <div class="vista-contenido">
1149 - <div class="contenido-centrado">
1150 - <!-- Título de sección -->
1151 - <header class="seccion-header seccion-header-centrado">
1152 - <h1 class="seccion-titulo-grande">¿En qué programas?</h1>
1153 - <p class="seccion-descripcion">Detalle de programas y actividades ejecutadas en la última semana</p>
1154 - </header>
1155 -
1156 - <div class="timeline-search">
1157 - <svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
1158 - <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
1159 - </svg>
1160 - <input
1161 - type="text"
1162 - placeholder="Buscar entidad..."
1163 - bind:value={busquedaTimeline}
1164 - class="timeline-search-input"
1165 - />
1166 - </div>
1167 -
1168 - <div class="timeline-cards">
1169 - {#each timelineByEntity as entidad}
1170 - <div class="timeline-card">
1171 - <div class="timeline-card-header">
1172 - <div class="timeline-card-entity">
1173 - <span class="timeline-card-area">{entidad.entidad_desc_area}</span>
1174 - <span class="timeline-card-nombre">{entidad.entidad_desc_entidad}</span>
1175 - </div>
1176 - <div class="timeline-card-total">
1177 - <span class="timeline-card-total-label">Ejecutado</span>
1178 - <span class="timeline-card-total-monto">Bs. {formatearNumeroCompleto(entidad.total)}</span>
1179 - </div>
1180 - </div>
1181 - <div class="timeline-card-body">
1182 - {#each cardsExpandidas[entidad.entidad] ? entidad.programas : entidad.programas.slice(0, 8) as prog}
1183 - <div class="timeline-prog-row">
1184 - <div class="timeline-prog-info">
1185 - <span class="timeline-prog-programa">{prog.programa}</span>
1186 - <span class="timeline-prog-actividad">{prog.actividad}</span>
1187 - </div>
1188 - <span class="timeline-prog-monto">Bs. {formatearNumeroCompleto(prog.devengado)}</span>
1189 - </div>
1190 - {/each}
1191 -
1192 - {#if entidad.programas.length > 8}
1193 - <button
1194 - class="timeline-ver-mas"
1195 - onclick={() => {
1196 - cardsExpandidas[entidad.entidad] = !cardsExpandidas[entidad.entidad];
1197 - cardsExpandidas = { ...cardsExpandidas };
1198 - }}
1199 - >
1200 - {cardsExpandidas[entidad.entidad] ? 'Ver menos' : `Ver ${entidad.programas.length - 8} más...`}
1201 - </button>
1202 - {/if}
1203 - </div>
1204 - </div>
1205 - {/each}
1206 - </div>
1207 - </div>
1208 - </div>
1209 - </section>
1210 1208
1211 <!-- SECCIÓN 4: Evolución temporal --> 1209 <!-- SECCIÓN 4: Evolución temporal -->
1212 - <section class="vista-seccion" id="evolucion"> 1210 + <section class="vista-seccion" id="evolucion" style="display: none;">
1213 <div class="vista-contenido"> 1211 <div class="vista-contenido">
1214 <div class="contenido-centrado"> 1212 <div class="contenido-centrado">
1215 <!-- Título de sección --> 1213 <!-- Título de sección -->
......
This diff could not be displayed because it is too large.