Rafael Lopez

geo

1 +const API_BASE = 'http://136.112.29.74/api/acteco';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const gestion = url.searchParams.get('gestion') || '';
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400,
10 + headers: { 'Content-Type': 'application/json' }
11 + });
12 + }
13 +
14 + const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
15 + if (gestion) apiUrl.searchParams.set('gestion', gestion);
16 +
17 + try {
18 + const response = await fetch(apiUrl.toString());
19 + if (!response.ok) {
20 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
21 + status: response.status,
22 + headers: { 'Content-Type': 'application/json' }
23 + });
24 + }
25 + const data = await response.json();
26 + return new Response(JSON.stringify(data), {
27 + headers: { 'Content-Type': 'application/json' }
28 + });
29 + } catch (err) {
30 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
31 + status: 500,
32 + headers: { 'Content-Type': 'application/json' }
33 + });
34 + }
35 +}
1 +const API_BASE = 'http://136.112.29.74/api/finfun';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const gestion = url.searchParams.get('gestion') || '';
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400,
10 + headers: { 'Content-Type': 'application/json' }
11 + });
12 + }
13 +
14 + const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
15 + if (gestion) apiUrl.searchParams.set('gestion', gestion);
16 +
17 + try {
18 + const response = await fetch(apiUrl.toString());
19 + if (!response.ok) {
20 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
21 + status: response.status,
22 + headers: { 'Content-Type': 'application/json' }
23 + });
24 + }
25 + const data = await response.json();
26 + return new Response(JSON.stringify(data), {
27 + headers: { 'Content-Type': 'application/json' }
28 + });
29 + } catch (err) {
30 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
31 + status: 500,
32 + headers: { 'Content-Type': 'application/json' }
33 + });
34 + }
35 +}
1 +const API_BASE = 'http://136.112.29.74/api/objeto';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const gestion = url.searchParams.get('gestion') || '';
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400,
10 + headers: { 'Content-Type': 'application/json' }
11 + });
12 + }
13 +
14 + const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
15 + if (gestion) apiUrl.searchParams.set('gestion', gestion);
16 +
17 + try {
18 + const response = await fetch(apiUrl.toString());
19 + if (!response.ok) {
20 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
21 + status: response.status,
22 + headers: { 'Content-Type': 'application/json' }
23 + });
24 + }
25 + const data = await response.json();
26 + return new Response(JSON.stringify(data), {
27 + headers: { 'Content-Type': 'application/json' }
28 + });
29 + } catch (err) {
30 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
31 + status: 500,
32 + headers: { 'Content-Type': 'application/json' }
33 + });
34 + }
35 +}
1 // Proxy para evitar CORS 1 // Proxy para evitar CORS
2 -const API_BASE = 'http://136.112.29.74/api/proyecto/search'; 2 +const API_BASE = 'http://136.112.29.74/api/search';
3 3
4 export async function GET({ url }) { 4 export async function GET({ url }) {
5 const q = url.searchParams.get('q') || ''; 5 const q = url.searchParams.get('q') || '';
......
...@@ -2,8 +2,12 @@ ...@@ -2,8 +2,12 @@
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 import { tweened } from 'svelte/motion'; 3 import { tweened } from 'svelte/motion';
4 import { cubicOut } from 'svelte/easing'; 4 import { cubicOut } from 'svelte/easing';
5 + import { goto } from '$app/navigation';
6 + import { page } from '$app/stores';
5 import { supabase } from '$lib/supabase'; 7 import { supabase } from '$lib/supabase';
8 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte'; 9 import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
10 + import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
7 import BarChart from '$lib/components/objeto/BarChart.svelte'; 11 import BarChart from '$lib/components/objeto/BarChart.svelte';
8 import EntitySelector from '$lib/components/objeto/EntitySelector.svelte'; 12 import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
9 import { initIndex } from '$lib/stores/searchStore'; 13 import { initIndex } from '$lib/stores/searchStore';
...@@ -19,6 +23,7 @@ ...@@ -19,6 +23,7 @@
19 let showDefinicionesModal = $state(false); 23 let showDefinicionesModal = $state(false);
20 let showMobileSidebar = $state(false); 24 let showMobileSidebar = $state(false);
21 let metrica = $state('percapita'); // 'percapita' | 'monto' 25 let metrica = $state('percapita'); // 'percapita' | 'monto'
26 + let vista = $state('agregado'); // 'agregado' | 'comparar'
22 27
23 // Parsear descripciones 28 // Parsear descripciones
24 function parseDescripciones(str) { 29 function parseDescripciones(str) {
...@@ -162,9 +167,239 @@ ...@@ -162,9 +167,239 @@
162 if (data) entidades = data; 167 if (data) entidades = data;
163 } 168 }
164 169
170 + // ══════════════════════════════════════════════════════════════
171 + // COMPARACIÓN
172 + // ══════════════════════════════════════════════════════════════
173 + const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
174 + const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
175 + const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
176 + const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
177 +
178 + let hoveredYearComparar = $state(null);
179 + let loadingComparacion = $state(false);
180 + let isDefaultSelection = $state(false);
181 + let alertDefaultDismissed = $state(false);
182 + let alertRangosDismissed = $state(false);
183 +
184 + let entidadCompararA = $state(null);
185 + let entidadCompararB = $state(null);
186 + let datosCompararA = $state({ anual: [], historico: null });
187 + let datosCompararB = $state({ anual: [], historico: null });
188 +
189 + let dropdownAOpen = $state(false);
190 + let dropdownBOpen = $state(false);
191 + let searchA = $state('');
192 + let searchB = $state('');
193 + let entityLimitA = $state(30);
194 + let entityLimitB = $state(30);
195 +
196 + let filteredEntidadesA = $derived.by(() => {
197 + if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
198 + const q = searchA.toLowerCase();
199 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitA);
200 + });
201 +
202 + let filteredEntidadesB = $derived.by(() => {
203 + if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
204 + const q = searchB.toLowerCase();
205 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitB);
206 + });
207 +
208 + async function loadDatosEntidad(entidad) {
209 + const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
210 + .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', entidad.entidad).order('gestion');
211 + if (data) {
212 + return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
213 + }
214 + return { anual: [], historico: null };
215 + }
216 +
217 + async function initComparacion() {
218 + if (entidadCompararA || entidadCompararB) return;
219 + if (selectedEntity) {
220 + entidadCompararA = selectedEntity;
221 + loadingComparacion = true;
222 + datosCompararA = await loadDatosEntidad(selectedEntity);
223 + loadingComparacion = false;
224 + isDefaultSelection = false;
225 + } else if (topEntidadesTotal.length >= 2) {
226 + const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
227 + const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
228 + if (top1 && top2) {
229 + isDefaultSelection = true;
230 + loadingComparacion = true;
231 + entidadCompararA = top1;
232 + datosCompararA = await loadDatosEntidad(top1);
233 + entidadCompararB = top2;
234 + datosCompararB = await loadDatosEntidad(top2);
235 + loadingComparacion = false;
236 + }
237 + }
238 + }
239 +
240 + async function selectEntidadA(entidad) {
241 + entidadCompararA = entidad;
242 + dropdownAOpen = false;
243 + searchA = '';
244 + entityLimitA = 30;
245 + isDefaultSelection = false;
246 + loadingComparacion = true;
247 + datosCompararA = await loadDatosEntidad(entidad);
248 + loadingComparacion = false;
249 + }
250 +
251 + async function selectEntidadB(entidad) {
252 + entidadCompararB = entidad;
253 + dropdownBOpen = false;
254 + searchB = '';
255 + entityLimitB = 30;
256 + isDefaultSelection = false;
257 + loadingComparacion = true;
258 + datosCompararB = await loadDatosEntidad(entidad);
259 + loadingComparacion = false;
260 + }
261 +
262 + function handleEntityScroll(event, type) {
263 + const el = event.target;
264 + if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
265 + if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
266 + if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
267 + }
268 + }
269 +
270 + let rangoAños = $state('todo');
271 +
272 + let añosComunesTotales = $derived.by(() => {
273 + const añosA = datosCompararA.anual.map(d => d.gestion);
274 + const añosB = datosCompararB.anual.map(d => d.gestion);
275 + if (añosA.length && añosB.length) {
276 + const setB = new Set(añosB);
277 + return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
278 + }
279 + if (añosA.length) return [...añosA].sort((a, b) => a - b);
280 + if (añosB.length) return [...añosB].sort((a, b) => a - b);
281 + return [];
282 + });
283 +
284 + let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
285 +
286 + let rangoInfoA = $derived.by(() => {
287 + if (!datosCompararA.anual.length) return null;
288 + const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
289 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
290 + });
291 +
292 + let rangoInfoB = $derived.by(() => {
293 + if (!datosCompararB.anual.length) return null;
294 + const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
295 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
296 + });
297 +
298 + let tienenRangosDiferentes = $derived.by(() => {
299 + if (!rangoInfoA || !rangoInfoB) return false;
300 + return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
301 + });
302 +
303 + let añosComparacion = $derived.by(() => {
304 + if (rangoAños === 'todo') return añosComunesTotales;
305 + return añosComunesTotales.slice(-parseInt(rangoAños));
306 + });
307 +
308 + let datosGraficoA = $derived.by(() => {
309 + const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
310 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
311 + });
312 +
313 + let datosGraficoB = $derived.by(() => {
314 + const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
315 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
316 + });
317 +
318 + let maxComparacion = $derived.by(() => {
319 + const all = [...datosGraficoA.map(d => d.per_capita || 0), ...datosGraficoB.map(d => d.per_capita || 0)];
320 + return Math.max(...all, 1) * 1.1;
321 + });
322 +
323 + let displayPeriodoComparar = $derived.by(() => {
324 + if (hoveredYearComparar) return hoveredYearComparar;
325 + if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
326 + return 'Selecciona entidades';
327 + });
328 +
329 + let displayPropA = $derived.by(() => {
330 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
331 + return datosCompararA.historico?.prop || 0;
332 + });
333 + let displayPropB = $derived.by(() => {
334 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
335 + return datosCompararB.historico?.prop || 0;
336 + });
337 + let displayRankingA = $derived.by(() => {
338 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
339 + return datosCompararA.historico?.ranking || '-';
340 + });
341 + let displayRankingB = $derived.by(() => {
342 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
343 + return datosCompararB.historico?.ranking || '-';
344 + });
345 + let displayNEntidadesA = $derived.by(() => {
346 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
347 + return datosCompararA.historico?.n_entidades || '-';
348 + });
349 + let displayNEntidadesB = $derived.by(() => {
350 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
351 + return datosCompararB.historico?.n_entidades || '-';
352 + });
353 +
354 + let isDarkMode = $state(false);
355 + $effect(() => {
356 + if (typeof document !== 'undefined') {
357 + const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
358 + checkDark();
359 + const observer = new MutationObserver(checkDark);
360 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
361 + return () => observer.disconnect();
362 + }
363 + });
364 +
365 + let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
366 + let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
367 +
368 + function toNumber(v) { return typeof v === 'number' ? v : 0; }
369 +
370 + $effect(() => {
371 + const year = hoveredYearComparar;
372 + const idx = year ? añosComparacion.indexOf(year) : -1;
373 + twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
374 + twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
375 + twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
376 + twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
377 + });
378 +
379 + // Watch vista changes
380 + let previousVista = 'agregado';
381 + $effect(() => {
382 + if (mounted && vista !== previousVista) {
383 + previousVista = vista;
384 + if (vista === 'comparar') initComparacion();
385 + }
386 + });
387 +
388 + function formatMonto(m) {
389 + if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
390 + if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
391 + if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
392 + return m?.toLocaleString('es-BO') || '0';
393 + }
394 +
395 + // ══════════════════════════════════════════════════════════════
396 +
165 onMount(() => { 397 onMount(() => {
166 mounted = true; 398 mounted = true;
167 initIndex(); 399 initIndex();
400 + // Read URL params
401 + const urlVista = $page.url.searchParams.get('vista');
402 + if (urlVista === 'comparar') vista = 'comparar';
168 }); 403 });
169 404
170 // Recargar entidades cuando cambia el objeto 405 // Recargar entidades cuando cambia el objeto
...@@ -174,6 +409,10 @@ ...@@ -174,6 +409,10 @@
174 if (code !== prevCodigo) { 409 if (code !== prevCodigo) {
175 prevCodigo = code; 410 prevCodigo = code;
176 selectedEntity = null; 411 selectedEntity = null;
412 + entidadCompararA = null;
413 + entidadCompararB = null;
414 + datosCompararA = { anual: [], historico: null };
415 + datosCompararB = { anual: [], historico: null };
177 loadEntidades(); 416 loadEntidades();
178 } 417 }
179 }); 418 });
...@@ -201,6 +440,7 @@ ...@@ -201,6 +440,7 @@
201 </a> 440 </a>
202 </nav> 441 </nav>
203 442
443 + {#if vista === 'agregado'}
204 <div class="dashboard-layout"> 444 <div class="dashboard-layout">
205 <!-- Sidebar --> 445 <!-- Sidebar -->
206 <aside class="dashboard-context" class:mobile-open={showMobileSidebar}> 446 <aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
...@@ -268,13 +508,13 @@ ...@@ -268,13 +508,13 @@
268 <span class="explore-count">7 →</span> 508 <span class="explore-count">7 →</span>
269 </div> 509 </div>
270 <div class="explore-scroll"> 510 <div class="explore-scroll">
271 - <a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card"> 511 + <div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
272 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75"> 512 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
273 <rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/> 513 <rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
274 </svg> 514 </svg>
275 <span class="explore-title">Comparar dos entidades</span> 515 <span class="explore-title">Comparar dos entidades</span>
276 <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span> 516 <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
277 - </a> 517 + </div>
278 <a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card"> 518 <a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
279 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75"> 519 <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
280 <path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/> 520 <path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
...@@ -456,6 +696,167 @@ ...@@ -456,6 +696,167 @@
456 </div> 696 </div>
457 </div> 697 </div>
458 {/if} 698 {/if}
699 +
700 + {:else}
701 + <!-- VISTA COMPARAR -->
702 + <main class="main-comparar">
703 + <div class="chart-card-comparar">
704 + <div class="chart-top-row">
705 + <div class="main-title-pills">
706 + <span class="pill pill-nivel">{nivelLabel}</span>
707 + <span class="pill pill-codigo">{objetoData.objeto}</span>
708 + </div>
709 + <VistaToggle bind:vista />
710 + </div>
711 +
712 + <h2 class="main-title-name">{objetoData.desc_objeto}</h2>
713 +
714 + <div class="comparador-selectors">
715 + <div class="selector-group comparador-dropdown-container">
716 + <span class="selector-dot" style="background: {colorA}"></span>
717 + <div class="relative" style="flex: 1;">
718 + <button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
719 + <span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
720 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
721 + </button>
722 + {#if dropdownAOpen}
723 + <div class="dropdown-panel comparador-panel">
724 + <div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
725 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
726 + {#each filteredEntidadesA as entity}
727 + <button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
728 + <span class="entity-code">{entity.entidad}</span>
729 + <span class="truncate">{entity.entidad_desc}</span>
730 + </button>
731 + {/each}
732 + </div>
733 + </div>
734 + {/if}
735 + </div>
736 + </div>
737 +
738 + <span class="vs">vs</span>
739 +
740 + <div class="selector-group comparador-dropdown-container">
741 + <span class="selector-dot" style="background: {colorB}"></span>
742 + <div class="relative" style="flex: 1;">
743 + <button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
744 + <span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
745 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
746 + </button>
747 + {#if dropdownBOpen}
748 + <div class="dropdown-panel comparador-panel">
749 + <div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
750 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
751 + {#each filteredEntidadesB as entity}
752 + <button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
753 + <span class="entity-code">{entity.entidad}</span>
754 + <span class="truncate">{entity.entidad_desc}</span>
755 + </button>
756 + {/each}
757 + </div>
758 + </div>
759 + {/if}
760 + </div>
761 + </div>
762 + </div>
763 +
764 + {#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
765 + <div class="comparador-hint">
766 + <span>Mostrando las dos entidades con mayor gasto. Puedes cambiar la selección arriba.</span>
767 + <button class="alert-close" onclick={() => alertDefaultDismissed = true}>✕</button>
768 + </div>
769 + {/if}
770 +
771 + {#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
772 + <div class="comparador-warning">
773 + <span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} años en común.</span>
774 + <button class="alert-close" onclick={() => alertRangosDismissed = true}>✕</button>
775 + </div>
776 + {/if}
777 +
778 + {#if loadingComparacion}
779 + <div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
780 + {:else if !entidadCompararA && !entidadCompararB}
781 + <div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
782 + {:else if añosComparacion.length === 0}
783 + <div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
784 + {:else}
785 + <div class="chart-header">
786 + <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodoComparar}</span></span>
787 + </div>
788 +
789 + <div class="chart-comparar-wrapper">
790 + <div class="chart-comparar-inner">
791 + <span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
792 + <span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
793 + <span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
794 + <div class="grid-line-comparar" style="bottom: 100%"></div>
795 + <div class="grid-line-comparar" style="bottom: 50%"></div>
796 + <div class="grid-line-comparar" style="bottom: 0%"></div>
797 + <div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
798 + {#each añosComparacion as año, i}
799 + <div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
800 + <div class="bars-pair">
801 + <div class="bar-comparar" style="height: {((datosGraficoA[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorA}"></div>
802 + <div class="bar-comparar" style="height: {((datosGraficoB[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorB}"></div>
803 + </div>
804 + </div>
805 + {/each}
806 + </div>
807 + </div>
808 + <div class="x-axis-comparar">
809 + {#each añosComparacion as año, i}
810 + {@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
811 + <span class="x-label-comparar" class:visible={show}>{año}</span>
812 + {/each}
813 + </div>
814 + </div>
815 +
816 + <div class="chart-legend">
817 + {#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
818 + {#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
819 + </div>
820 + {/if}
821 + </div>
822 +
823 + {#if soloUnaEntidad}
824 + <div class="comparador-hint comparador-hint-select">
825 + <span>Selecciona la segunda entidad para ver la comparación completa.</span>
826 + </div>
827 + {/if}
828 +
829 + {#if entidadCompararA && entidadCompararB}
830 + <div class="kpis-card-comparar">
831 + <div class="tabla-header">
832 + <span class="tabla-label"></span>
833 + <span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
834 + <span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
835 + </div>
836 + <div class="tabla-row">
837 + <span class="tabla-label">Gasto</span>
838 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
839 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
840 + </div>
841 + <div class="tabla-row">
842 + <span class="tabla-label">Per cápita</span>
843 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
844 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
845 + </div>
846 + <div class="tabla-row">
847 + <span class="tabla-label">Peso</span>
848 + <span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
849 + <span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
850 + </div>
851 + <div class="tabla-row">
852 + <span class="tabla-label">Ranking</span>
853 + <span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
854 + <span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
855 + </div>
856 + </div>
857 + {/if}
858 + </main>
859 + {/if}
459 </div> 860 </div>
460 861
461 <style> 862 <style>
...@@ -1298,4 +1699,324 @@ ...@@ -1298,4 +1699,324 @@
1298 :global(html.dark) .def-link { 1699 :global(html.dark) .def-link {
1299 color: var(--theme-accent, #C9A751); 1700 color: var(--theme-accent, #C9A751);
1300 } 1701 }
1702 +
1703 + /* ═══════════════════════════════════════════════════════════════
1704 + COMPARAR VIEW
1705 + ═══════════════════════════════════════════════════════════════ */
1706 + .main-comparar {
1707 + display: flex;
1708 + flex-direction: column;
1709 + gap: 0.75rem;
1710 + padding: 0.75rem 2rem 1.5rem;
1711 + width: 100%;
1712 + max-width: 1400px;
1713 + margin: 0 auto;
1714 + min-height: calc(100vh - 60px);
1715 + }
1716 +
1717 + .chart-card-comparar {
1718 + background: var(--theme-surface);
1719 + border-radius: 12px;
1720 + padding: 1rem 1.5rem;
1721 + display: flex;
1722 + flex-direction: column;
1723 + flex: 1;
1724 + min-height: 0;
1725 + }
1726 +
1727 + .comparador-selectors {
1728 + display: flex;
1729 + align-items: center;
1730 + gap: 1rem;
1731 + margin-bottom: 1rem;
1732 + }
1733 +
1734 + .selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
1735 + .selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
1736 + .vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
1737 + .relative { position: relative; }
1738 + .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1739 +
1740 + .selector-btn {
1741 + display: flex;
1742 + align-items: center;
1743 + gap: 0.5rem;
1744 + padding: 0.625rem 1rem;
1745 + background: var(--theme-body);
1746 + border: 1px solid var(--theme-borde);
1747 + border-radius: 8px;
1748 + font-size: 0.8125rem;
1749 + color: var(--theme-titulo);
1750 + cursor: pointer;
1751 + transition: border-color 0.2s;
1752 + }
1753 + .selector-btn:hover { border-color: var(--theme-accent); }
1754 + .comparador-btn { width: 100%; justify-content: space-between; }
1755 + .comparador-dropdown-container { position: relative; }
1756 +
1757 + .dropdown-panel.comparador-panel {
1758 + position: absolute;
1759 + top: calc(100% + 4px);
1760 + left: 0;
1761 + width: 100%;
1762 + min-width: 280px;
1763 + background: var(--theme-surface);
1764 + border: 1px solid var(--theme-borde);
1765 + border-radius: 10px;
1766 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1767 + z-index: 100;
1768 + overflow: hidden;
1769 + }
1770 +
1771 + .dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
1772 + .search-input {
1773 + width: 100%;
1774 + padding: 0.5rem 0.75rem;
1775 + border: 1px solid var(--theme-borde);
1776 + border-radius: 6px;
1777 + background: var(--theme-body);
1778 + color: var(--theme-titulo);
1779 + font-size: 0.8125rem;
1780 + outline: none;
1781 + }
1782 + .search-input:focus { border-color: var(--theme-accent); }
1783 +
1784 + .dropdown-item {
1785 + display: flex;
1786 + align-items: center;
1787 + gap: 0.5rem;
1788 + width: 100%;
1789 + padding: 0.625rem 0.75rem;
1790 + border: none;
1791 + background: transparent;
1792 + color: var(--theme-titulo);
1793 + font-size: 0.8125rem;
1794 + text-align: left;
1795 + cursor: pointer;
1796 + transition: background 0.15s;
1797 + }
1798 + .dropdown-item:hover { background: var(--theme-surface-hover); }
1799 + .dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
1800 + .dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
1801 + .dropdown-list { max-height: 240px; overflow-y: auto; }
1802 + .entity-code { font-family: 'DM Mono', monospace; font-size: 0.6875rem; color: var(--theme-texto); min-width: 2rem; }
1803 +
1804 + .comparador-placeholder {
1805 + display: flex;
1806 + align-items: center;
1807 + justify-content: center;
1808 + min-height: 200px;
1809 + border: 1px dashed var(--theme-borde);
1810 + border-radius: 12px;
1811 + padding: 2rem;
1812 + text-align: center;
1813 + }
1814 + .comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
1815 +
1816 + .comparador-hint {
1817 + display: flex;
1818 + align-items: center;
1819 + gap: 0.5rem;
1820 + padding: 0.75rem 1rem;
1821 + background: rgba(107, 159, 212, 0.1);
1822 + border-radius: 8px;
1823 + font-size: 0.8125rem;
1824 + color: #6B9FD4;
1825 + }
1826 + .comparador-hint span { flex: 1; }
1827 + .comparador-hint-select { margin-top: 0.75rem; }
1828 +
1829 + .comparador-warning {
1830 + display: flex;
1831 + align-items: center;
1832 + gap: 0.5rem;
1833 + padding: 0.75rem 1rem;
1834 + background: rgba(230, 176, 75, 0.1);
1835 + border-radius: 8px;
1836 + font-size: 0.8125rem;
1837 + color: #C9A751;
1838 + }
1839 + .comparador-warning span { flex: 1; }
1840 +
1841 + .alert-close {
1842 + flex-shrink: 0;
1843 + padding: 0.25rem;
1844 + border: none;
1845 + background: transparent;
1846 + color: inherit;
1847 + opacity: 0.6;
1848 + cursor: pointer;
1849 + border-radius: 4px;
1850 + }
1851 + .alert-close:hover { opacity: 1; }
1852 +
1853 + .chart-comparar-wrapper {
1854 + position: relative;
1855 + width: 100%;
1856 + flex: 1;
1857 + min-height: 180px;
1858 + }
1859 +
1860 + .chart-comparar-inner {
1861 + position: absolute;
1862 + top: 10px;
1863 + bottom: 30px;
1864 + left: 50px;
1865 + right: 10px;
1866 + }
1867 +
1868 + .y-label-comparar {
1869 + position: absolute;
1870 + left: -50px;
1871 + width: 45px;
1872 + text-align: right;
1873 + transform: translateY(50%);
1874 + font-family: 'DM Mono', monospace;
1875 + font-size: 0.6875rem;
1876 + color: var(--theme-texto);
1877 + white-space: nowrap;
1878 + }
1879 +
1880 + .grid-line-comparar {
1881 + position: absolute;
1882 + left: 0;
1883 + right: 0;
1884 + border-top: 0.5px dotted var(--theme-texto);
1885 + opacity: 0.15;
1886 + }
1887 +
1888 + .bars-comparar {
1889 + position: absolute;
1890 + inset: 0;
1891 + display: flex;
1892 + align-items: flex-end;
1893 + gap: 3px;
1894 + }
1895 +
1896 + .bar-group-comparar {
1897 + flex: 1;
1898 + height: 100%;
1899 + display: flex;
1900 + align-items: flex-end;
1901 + justify-content: center;
1902 + cursor: pointer;
1903 + }
1904 +
1905 + .bars-pair {
1906 + display: flex;
1907 + align-items: flex-end;
1908 + gap: 2px;
1909 + height: 100%;
1910 + width: 100%;
1911 + justify-content: center;
1912 + }
1913 +
1914 + .bar-comparar {
1915 + flex: 1;
1916 + max-width: 24px;
1917 + border-radius: 3px 3px 0 0;
1918 + min-height: 2px;
1919 + transition: opacity 0.15s;
1920 + }
1921 +
1922 + .bars-comparar:hover .bar-comparar { opacity: 0.35; }
1923 + .bar-group-comparar.active .bar-comparar { opacity: 1; }
1924 +
1925 + .x-axis-comparar {
1926 + position: absolute;
1927 + bottom: 0;
1928 + left: 50px;
1929 + right: 10px;
1930 + height: 30px;
1931 + display: flex;
1932 + align-items: center;
1933 + }
1934 +
1935 + .x-label-comparar {
1936 + flex: 1;
1937 + text-align: center;
1938 + font-family: 'DM Mono', monospace;
1939 + font-size: 0.6875rem;
1940 + color: var(--theme-texto);
1941 + opacity: 0;
1942 + transition: opacity 0.2s;
1943 + }
1944 + .x-label-comparar.visible { opacity: 1; }
1945 +
1946 + .chart-legend {
1947 + display: flex;
1948 + justify-content: center;
1949 + gap: 2rem;
1950 + margin-top: 0.5rem;
1951 + margin-bottom: 0.75rem;
1952 + }
1953 +
1954 + .legend-item {
1955 + display: flex;
1956 + align-items: center;
1957 + gap: 0.5rem;
1958 + font-size: 0.75rem;
1959 + color: var(--theme-texto);
1960 + }
1961 +
1962 + .legend-dot { width: 10px; height: 10px; border-radius: 3px; }
1963 +
1964 + .kpis-card-comparar {
1965 + background: var(--theme-surface);
1966 + border-radius: 12px;
1967 + padding: 0.75rem 1.25rem;
1968 + }
1969 +
1970 + .tabla-header {
1971 + display: grid;
1972 + grid-template-columns: 1fr 1.2fr 1.2fr;
1973 + gap: 0.75rem;
1974 + padding-bottom: 0.375rem;
1975 + border-bottom: 1px solid var(--theme-borde);
1976 + margin-bottom: 0.25rem;
1977 + }
1978 +
1979 + .tabla-header .tabla-val {
1980 + font-size: 0.625rem;
1981 + font-weight: 500;
1982 + text-transform: uppercase;
1983 + letter-spacing: 0.05em;
1984 + text-align: center;
1985 + }
1986 +
1987 + .tabla-entidad {
1988 + font-size: 0.6875rem;
1989 + line-height: 1.3;
1990 + word-break: break-word;
1991 + }
1992 +
1993 + .tabla-row {
1994 + display: grid;
1995 + grid-template-columns: 1fr 1.2fr 1.2fr;
1996 + gap: 0.75rem;
1997 + padding: 0.1875rem 0;
1998 + }
1999 +
2000 + .tabla-label {
2001 + font-size: 0.6875rem;
2002 + font-weight: 500;
2003 + color: var(--theme-texto);
2004 + text-transform: uppercase;
2005 + letter-spacing: 0.05em;
2006 + }
2007 +
2008 + .tabla-val {
2009 + font-size: 0.875rem;
2010 + font-weight: 500;
2011 + color: var(--theme-titulo);
2012 + text-align: center;
2013 + font-variant-numeric: tabular-nums;
2014 + }
2015 +
2016 + @media (max-width: 640px) {
2017 + .main-comparar { padding: 0.75rem; }
2018 + .chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
2019 + .comparador-selectors { flex-direction: column; gap: 0.75rem; }
2020 + .vs { display: none; }
2021 + }
1301 </style> 2022 </style>
......
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 import { get } from 'svelte/store'; 3 import { get } from 'svelte/store';
4 + import { goto } from '$app/navigation';
5 + import { page } from '$app/stores';
4 import * as d3 from 'd3'; 6 import * as d3 from 'd3';
5 import { supabase } from '$lib/supabase'; 7 import { supabase } from '$lib/supabase';
6 import { mapaCache } from '$lib/stores/mapaCache'; 8 import { mapaCache } from '$lib/stores/mapaCache';
...@@ -15,7 +17,7 @@ ...@@ -15,7 +17,7 @@
15 let hoveredMunicipio = $state(null); 17 let hoveredMunicipio = $state(null);
16 let drawerOpen = $state(false); 18 let drawerOpen = $state(false);
17 let hoveredQuintil = $state(-1); 19 let hoveredQuintil = $state(-1);
18 - let activeQuintiles = $state(new Set([0, 1, 2, 3, 4])); 20 + let activeQuintiles = $state(new Set([0, 1, 2, 3]));
19 let sortDrawer = $state('monto'); 21 let sortDrawer = $state('monto');
20 let sortDrawerOrder = $state('desc'); 22 let sortDrawerOrder = $state('desc');
21 let mostrarDepartamentos = $state(false); 23 let mostrarDepartamentos = $state(false);
...@@ -26,11 +28,123 @@ ...@@ -26,11 +28,123 @@
26 28
27 const CLASIFICADORES_MAPA = [ 29 const CLASIFICADORES_MAPA = [
28 { id: 'total', label: 'Gasto total', disponible: true }, 30 { id: 'total', label: 'Gasto total', disponible: true },
29 - { id: 'objeto', label: 'Objetos de gasto', disponible: false }, 31 + { id: 'objeto', label: 'Objetos de gasto', disponible: true },
30 - { id: 'finfun', label: 'Finalidad y función', disponible: false }, 32 + { id: 'finfun', label: 'Finalidad y función', disponible: true },
31 - { id: 'acteco', label: 'Sectores económicos', disponible: false }, 33 + { id: 'acteco', label: 'Sectores económicos', disponible: true },
32 ]; 34 ];
33 35
36 + // Búsqueda de partida para clasificadores (objeto, finfun, acteco)
37 + let clasSearchVal = $state('');
38 + let clasSearchResults = $state([]);
39 + let clasSearchFocused = $state(false);
40 + let clasSearchLoading = $state(false);
41 + let clasSeleccionado = $state(null);
42 + let clasDebounce;
43 +
44 + const CLAS_SEARCH_MAP = {
45 + objeto: { class_: 'objeto', codigoFn: (meta) => meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '' },
46 + finfun: { class_: 'finfun', codigoFn: (meta) => {
47 + const fin = String(meta.finfun_finalidad || '');
48 + if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
49 + if (meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}`;
50 + return fin;
51 + }},
52 + acteco: { class_: 'acteco', codigoFn: (meta) => {
53 + const s = String(meta.acteco_sector ? Math.round(meta.acteco_sector) : '');
54 + if (meta.acteco_actividad !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}.${Math.round(meta.acteco_actividad)}`;
55 + if (meta.acteco_subsector !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}`;
56 + return s;
57 + }},
58 + };
59 +
60 + const CLAS_API_MAP = {
61 + objeto: '/api/objeto-ubigeos',
62 + finfun: '/api/finfun-ubigeos',
63 + acteco: '/api/acteco-ubigeos',
64 + };
65 +
66 + function parseMetadatos(meta) {
67 + if (!meta) return {};
68 + if (typeof meta === 'object') return meta;
69 + try { return JSON.parse(meta); } catch { return {}; }
70 + }
71 +
72 + async function buscarClasificador(query) {
73 + clasSearchLoading = true;
74 + const cfg = CLAS_SEARCH_MAP[clasificadorSeleccionado];
75 + if (!cfg) { clasSearchLoading = false; return; }
76 + try {
77 + const params = new URLSearchParams({ q: query, per_page: '15', is_class: 'true', class_: cfg.class_ });
78 + const res = await fetch(`/api/search?${params}`);
79 + if (!res.ok) throw new Error();
80 + const data = await res.json();
81 + clasSearchResults = (data.hits || []).map(hit => {
82 + const meta = parseMetadatos(hit.document.metadatos);
83 + return { codigo: cfg.codigoFn(meta), nombre: hit.document.texto, highlight: hit.highlights?.[0]?.snippet || hit.document.texto };
84 + });
85 + } catch { clasSearchResults = []; }
86 + clasSearchLoading = false;
87 + }
88 +
89 + function handleClasInput(e) {
90 + clasSearchVal = e.target.value;
91 + clearTimeout(clasDebounce);
92 + if (clasSearchVal.length >= 2) {
93 + clasDebounce = setTimeout(() => buscarClasificador(clasSearchVal), 200);
94 + } else {
95 + clasSearchResults = [];
96 + }
97 + }
98 +
99 + async function seleccionarClasificador(item) {
100 + clasSeleccionado = item;
101 + clasSearchVal = '';
102 + clasSearchResults = [];
103 + clasSearchFocused = false;
104 + await cargarClasUbigeos(item.codigo, gestionSeleccionada);
105 + updateMapUrl();
106 + }
107 +
108 + function limpiarClasificador() {
109 + clasSeleccionado = null;
110 + resumen = [];
111 + updateMapUrl();
112 + }
113 +
114 + // Cache de datos clasificador-ubigeo
115 + let clasUbigeoCache = $state({});
116 +
117 + async function cargarClasUbigeos(codigo, gestion) {
118 + const apiEndpoint = CLAS_API_MAP[clasificadorSeleccionado];
119 + if (!apiEndpoint) return;
120 + const key = `${clasificadorSeleccionado}_${codigo}_${gestion}`;
121 + if (clasUbigeoCache[key]) {
122 + resumen = clasUbigeoCache[key];
123 + return;
124 + }
125 + const params = new URLSearchParams({ codigo });
126 + if (gestion) params.set('gestion', gestion);
127 + try {
128 + const res = await fetch(`${apiEndpoint}?${params}`);
129 + const data = await res.json();
130 + const mapped = (data || [])
131 + .filter(d => d.ubigeo !== '0.0.0')
132 + .map(d => ({
133 + codigo: d.ubigeo,
134 + desc: d.desc_ubigeo,
135 + gestion: d.gestion,
136 + devengado: d.monto,
137 + ranking: d.ranking,
138 + per_capita: d.per_capita,
139 + departamento: d.desc_departamento
140 + }));
141 + clasUbigeoCache[key] = mapped;
142 + resumen = mapped;
143 + } catch {
144 + resumen = [];
145 + }
146 + }
147 +
34 function titleCase(str) { 148 function titleCase(str) {
35 if (!str) return ''; 149 if (!str) return '';
36 return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); 150 return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
...@@ -42,6 +156,7 @@ ...@@ -42,6 +156,7 @@
42 const map = {}; 156 const map = {};
43 mapaData.municipios.features.forEach(f => { 157 mapaData.municipios.features.forEach(f => {
44 map[String(f.properties.codigo)] = f.properties.DEPARTAMEN; 158 map[String(f.properties.codigo)] = f.properties.DEPARTAMEN;
159 + if (f.properties.ubigeo) map[f.properties.ubigeo] = f.properties.DEPARTAMEN;
45 }); 160 });
46 return map; 161 return map;
47 }); 162 });
...@@ -68,24 +183,26 @@ ...@@ -68,24 +183,26 @@
68 183
69 // Crear mapa de datos por código (cacheado, no función) 184 // Crear mapa de datos por código (cacheado, no función)
70 let datosPorCodigo = $derived.by(() => { 185 let datosPorCodigo = $derived.by(() => {
71 - console.time('datosPorCodigo');
72 const map = {}; 186 const map = {};
187 + const esClasificador = clasificadorSeleccionado !== 'total' && clasSeleccionado;
73 resumen.forEach(d => { 188 resumen.forEach(d => {
74 - const val = modoPerCapita && poblacionMap[d.codigo] 189 + let val;
75 - ? d.devengado / poblacionMap[d.codigo] 190 + if (modoPerCapita) {
76 - : d.devengado; 191 + val = esClasificador ? (d.per_capita || 0) : (poblacionMap[d.codigo] ? d.devengado / poblacionMap[d.codigo] : d.devengado);
192 + } else {
193 + val = d.devengado;
194 + }
77 map[d.codigo] = { ...d, valor: val }; 195 map[d.codigo] = { ...d, valor: val };
78 }); 196 });
79 - console.timeEnd('datosPorCodigo');
80 return map; 197 return map;
81 }); 198 });
82 199
83 // Detectar modo oscuro 200 // Detectar modo oscuro
84 let isDark = $state(false); 201 let isDark = $state(false);
85 202
86 - // 5 quintiles 203 + // 4 cuartiles
87 - const colorsLight = ['#f2ece6', '#e0c8b0', '#c4897d', '#a86858', '#8B4A3A']; 204 + const colorsLight = ['#e0c8b0', '#c4897d', '#a86858', '#8B4A3A'];
88 - const colorsDark = ['#2E2B27', '#4A4035', '#6B5A48', '#9A8050', '#C9A751']; 205 + const colorsDark = ['#4A4035', '#6B5A48', '#9A8050', '#C9A751'];
89 206
90 let palette = $derived(isDark ? colorsDark : colorsLight); 207 let palette = $derived(isDark ? colorsDark : colorsLight);
91 208
...@@ -151,9 +268,9 @@ ...@@ -151,9 +268,9 @@
151 function opacidadMuni(codigo) { 268 function opacidadMuni(codigo) {
152 if (hoveredQuintil === -1) return 1; 269 if (hoveredQuintil === -1) return 1;
153 const datos = datosPorCodigo[codigo]; 270 const datos = datosPorCodigo[codigo];
154 - if (!datos) return 0.15; 271 + if (!datos) return 0.02;
155 const qi = getQuintilIdx(datos.valor); 272 const qi = getQuintilIdx(datos.valor);
156 - return qi === hoveredQuintil ? 1 : 0.15; 273 + return qi === hoveredQuintil ? 1 : 0.02;
157 } 274 }
158 275
159 function toggleQuintil(idx) { 276 function toggleQuintil(idx) {
...@@ -236,9 +353,15 @@ ...@@ -236,9 +353,15 @@
236 let resumenCache = $state({}); 353 let resumenCache = $state({});
237 354
238 async function cargarGestion(gestion) { 355 async function cargarGestion(gestion) {
239 - console.time('cargarGestion'); 356 + // Si hay un clasificador seleccionado, cargar desde la API
357 + if (clasificadorSeleccionado !== 'total' && clasSeleccionado) {
358 + await cargarClasUbigeos(clasSeleccionado.codigo, gestion);
359 + poblacionMap = await getPoblacionAno(gestion);
360 + return;
361 + }
362 +
363 + // Modo total: cargar desde Supabase
240 if (!resumenCache[gestion]) { 364 if (!resumenCache[gestion]) {
241 - console.time('supabase');
242 const { data: rows } = await supabase 365 const { data: rows } = await supabase
243 .schema('ppto') 366 .schema('ppto')
244 .from('entidad_resumen') 367 .from('entidad_resumen')
...@@ -248,15 +371,44 @@ ...@@ -248,15 +371,44 @@
248 .eq('gestion', gestion) 371 .eq('gestion', gestion)
249 .order('devengado', { ascending: false }) 372 .order('devengado', { ascending: false })
250 .limit(500); 373 .limit(500);
251 - console.timeEnd('supabase');
252 resumenCache[gestion] = rows || []; 374 resumenCache[gestion] = rows || [];
253 } 375 }
254 resumen = resumenCache[gestion]; 376 resumen = resumenCache[gestion];
255 -
256 - console.time('poblacion');
257 poblacionMap = await getPoblacionAno(gestion); 377 poblacionMap = await getPoblacionAno(gestion);
258 - console.timeEnd('poblacion'); 378 + }
259 - console.timeEnd('cargarGestion'); 379 +
380 + // Actualizar URL sin recargar
381 + function updateMapUrl() {
382 + const url = new URL($page.url);
383 + url.searchParams.delete('clas');
384 + url.searchParams.delete('codigo');
385 + url.searchParams.delete('gestion');
386 +
387 + if (gestionSeleccionada !== 2025) url.searchParams.set('gestion', gestionSeleccionada);
388 + if (clasificadorSeleccionado !== 'total' && clasSeleccionado) {
389 + url.searchParams.set('clas', clasificadorSeleccionado);
390 + url.searchParams.set('codigo', clasSeleccionado.codigo);
391 + }
392 +
393 + goto(url.toString(), { replaceState: true, noScroll: true });
394 + }
395 +
396 + // Resolver nombre de partida por código desde Typesense
397 + async function resolverNombre(clas, codigo) {
398 + const cfg = CLAS_SEARCH_MAP[clas];
399 + if (!cfg) return codigo;
400 + try {
401 + const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ });
402 + const res = await fetch(`/api/search?${params}`);
403 + if (!res.ok) return codigo;
404 + const data = await res.json();
405 + for (const hit of (data.hits || [])) {
406 + const meta = parseMetadatos(hit.document.metadatos);
407 + const hitCodigo = cfg.codigoFn(meta);
408 + if (hitCodigo === codigo) return hit.document.texto;
409 + }
410 + } catch {}
411 + return codigo;
260 } 412 }
261 413
262 onMount(async () => { 414 onMount(async () => {
...@@ -284,6 +436,23 @@ ...@@ -284,6 +436,23 @@
284 }); 436 });
285 themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); 437 themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
286 438
439 + // Leer URL params
440 + const urlClas = $page.url.searchParams.get('clas');
441 + const urlCodigo = $page.url.searchParams.get('codigo');
442 + const urlGestion = $page.url.searchParams.get('gestion');
443 +
444 + if (urlGestion) {
445 + gestionSeleccionada = parseInt(urlGestion);
446 + await cargarGestion(gestionSeleccionada);
447 + }
448 +
449 + if (urlClas && urlCodigo && CLAS_API_MAP[urlClas]) {
450 + clasificadorSeleccionado = urlClas;
451 + const nombre = await resolverNombre(urlClas, urlCodigo);
452 + clasSeleccionado = { codigo: urlCodigo, nombre };
453 + await cargarClasUbigeos(urlCodigo, gestionSeleccionada);
454 + }
455 +
287 function handleClickOutside(e) { 456 function handleClickOutside(e) {
288 if (gestionDropdownOpen && !e.target.closest('.gestion-selector')) { 457 if (gestionDropdownOpen && !e.target.closest('.gestion-selector')) {
289 gestionDropdownOpen = false; 458 gestionDropdownOpen = false;
...@@ -329,7 +498,20 @@ ...@@ -329,7 +498,20 @@
329 class="clasificador-option" 498 class="clasificador-option"
330 class:active={clasificadorSeleccionado === cls.id} 499 class:active={clasificadorSeleccionado === cls.id}
331 class:disabled={!cls.disponible} 500 class:disabled={!cls.disponible}
332 - onclick={() => { if (cls.disponible) { clasificadorSeleccionado = cls.id; clasificadorDropdownOpen = false; } }} 501 + onclick={() => {
502 + if (cls.disponible) {
503 + clasificadorSeleccionado = cls.id;
504 + clasificadorDropdownOpen = false;
505 + clasSeleccionado = null;
506 + clasSearchVal = '';
507 + clasSearchResults = [];
508 + if (cls.id === 'total') {
509 + cargarGestion(gestionSeleccionada);
510 + } else {
511 + resumen = [];
512 + }
513 + }
514 + }}
333 > 515 >
334 <span class="option-label">{cls.label}</span> 516 <span class="option-label">{cls.label}</span>
335 {#if cls.sub} 517 {#if cls.sub}
...@@ -340,6 +522,45 @@ ...@@ -340,6 +522,45 @@
340 </div> 522 </div>
341 {/if} 523 {/if}
342 </div> 524 </div>
525 + {#if clasificadorSeleccionado !== 'total'}
526 + <span class="titulo-sep">·</span>
527 + <div class="objeto-search-mapa">
528 + {#if clasSeleccionado}
529 + <div class="objeto-selected-pill">
530 + <span class="objeto-pill-code">{clasSeleccionado.codigo}</span>
531 + <span class="objeto-pill-name">{clasSeleccionado.nombre}</span>
532 + <button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button>
533 + </div>
534 + {:else}
535 + <div class="objeto-search-wrapper" class:focused={clasSearchFocused}>
536 + <input
537 + type="text"
538 + value={clasSearchVal}
539 + oninput={handleClasInput}
540 + onfocus={() => clasSearchFocused = true}
541 + onblur={() => setTimeout(() => clasSearchFocused = false, 150)}
542 + placeholder="Buscar {clasificadorSeleccionado === 'objeto' ? 'partida' : clasificadorSeleccionado === 'finfun' ? 'finalidad o función' : 'sector'}..."
543 + />
544 + {#if clasSearchFocused && clasSearchVal.length >= 2}
545 + <div class="objeto-search-dropdown">
546 + {#if clasSearchLoading}
547 + <div class="objeto-search-msg">Buscando...</div>
548 + {:else if clasSearchResults.length === 0}
549 + <div class="objeto-search-msg">Sin resultados</div>
550 + {:else}
551 + {#each clasSearchResults as result}
552 + <button class="objeto-search-item" onclick={() => seleccionarClasificador(result)}>
553 + <span class="objeto-search-code">{result.codigo}</span>
554 + <span class="objeto-search-name">{@html result.highlight}</span>
555 + </button>
556 + {/each}
557 + {/if}
558 + </div>
559 + {/if}
560 + </div>
561 + {/if}
562 + </div>
563 + {/if}
343 <span class="titulo-sep">por geografía</span> 564 <span class="titulo-sep">por geografía</span>
344 <div class="gestion-selector"> 565 <div class="gestion-selector">
345 <button class="gestion-btn" onclick={() => { gestionDropdownOpen = !gestionDropdownOpen; }}> 566 <button class="gestion-btn" onclick={() => { gestionDropdownOpen = !gestionDropdownOpen; }}>
...@@ -350,11 +571,11 @@ ...@@ -350,11 +571,11 @@
350 </button> 571 </button>
351 {#if gestionDropdownOpen} 572 {#if gestionDropdownOpen}
352 <div class="gestion-dropdown"> 573 <div class="gestion-dropdown">
353 - {#each data.gestiones as g} 574 + {#each [...data.gestiones].reverse() as g}
354 <button 575 <button
355 class="gestion-option" 576 class="gestion-option"
356 class:active={gestionSeleccionada === g} 577 class:active={gestionSeleccionada === g}
357 - onclick={() => { gestionSeleccionada = g; gestionDropdownOpen = false; cargarGestion(g); }} 578 + onclick={async () => { gestionSeleccionada = g; gestionDropdownOpen = false; await cargarGestion(g); updateMapUrl(); }}
358 > 579 >
359 {g} 580 {g}
360 </button> 581 </button>
...@@ -390,7 +611,7 @@ ...@@ -390,7 +611,7 @@
390 <path d={pathGen(mapaData.bolivia)} class="mapa-pais-fill" /> 611 <path d={pathGen(mapaData.bolivia)} class="mapa-pais-fill" />
391 <path d={pathGen(mapaData.bolivia)} class="mapa-pais-outline" /> 612 <path d={pathGen(mapaData.bolivia)} class="mapa-pais-outline" />
392 {#each mapaData.municipios.features as feat} 613 {#each mapaData.municipios.features as feat}
393 - {@const cod = String(feat.properties.codigo)} 614 + {@const cod = clasificadorSeleccionado !== 'total' ? feat.properties.ubigeo : String(feat.properties.codigo)}
394 {@const datos = datosPorCodigo[cod]} 615 {@const datos = datosPorCodigo[cod]}
395 {@const visible = enRango(cod)} 616 {@const visible = enRango(cod)}
396 {@const color = datos && visible ? colorScale(datos.valor) : 'transparent'} 617 {@const color = datos && visible ? colorScale(datos.valor) : 'transparent'}
...@@ -398,8 +619,8 @@ ...@@ -398,8 +619,8 @@
398 <path 619 <path
399 d={pathGen(feat)} 620 d={pathGen(feat)}
400 fill={color} 621 fill={color}
401 - stroke={hoveredMunicipio === cod ? (isDark ? '#F5F0E8' : '#1d1d1f') : (visible ? (isDark ? 'rgba(255,255,255,0.05)' : 'white') : 'transparent')} 622 + stroke={hoveredMunicipio === cod ? (isDark ? '#F5F0E8' : '#1d1d1f') : (hoveredQuintil !== -1 && datos && getQuintilIdx(datos.valor) === hoveredQuintil) ? (isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.25)') : (visible ? (isDark ? 'rgba(255,255,255,0.05)' : 'white') : 'transparent')}
402 - stroke-width={hoveredMunicipio === cod ? 2 : 0.3} 623 + stroke-width={hoveredMunicipio === cod ? 2 : (hoveredQuintil !== -1 && datos && getQuintilIdx(datos.valor) === hoveredQuintil) ? 0.8 : 0.3}
403 opacity={visible ? opacidadMuni(cod) : 0} 624 opacity={visible ? opacidadMuni(cod) : 0}
404 style="cursor: pointer; transition: fill 0.2s, opacity 0.2s;" 625 style="cursor: pointer; transition: fill 0.2s, opacity 0.2s;"
405 onmouseenter={() => { if (visible) hoveredMunicipio = cod; }} 626 onmouseenter={() => { if (visible) hoveredMunicipio = cod; }}
...@@ -422,6 +643,8 @@ ...@@ -422,6 +643,8 @@
422 <button 643 <button
423 class="quintil-item" 644 class="quintil-item"
424 class:dimmed={!activeQuintiles.has(i)} 645 class:dimmed={!activeQuintiles.has(i)}
646 + class:hovered={hoveredQuintil === i}
647 + class:faded={hoveredQuintil !== -1 && hoveredQuintil !== i}
425 onmouseenter={() => { hoveredQuintil = i; }} 648 onmouseenter={() => { hoveredQuintil = i; }}
426 onclick={() => { toggleQuintil(i); }} 649 onclick={() => { toggleQuintil(i); }}
427 > 650 >
...@@ -639,6 +862,115 @@ ...@@ -639,6 +862,115 @@
639 862
640 .option-label { flex: 1; } 863 .option-label { flex: 1; }
641 .option-sub { font-size: 0.65rem; color: var(--theme-texto); opacity: 0.5; } 864 .option-sub { font-size: 0.65rem; color: var(--theme-texto); opacity: 0.5; }
865 + /* Buscador de objeto en mapa */
866 + .objeto-search-mapa {
867 + position: relative;
868 + }
869 +
870 + .objeto-search-wrapper {
871 + display: flex;
872 + align-items: center;
873 + background: var(--theme-surface);
874 + border: 1px solid var(--theme-borde);
875 + border-radius: 6px;
876 + padding: 0.25rem 0.5rem;
877 + min-width: 180px;
878 + }
879 + .objeto-search-wrapper.focused { border-color: var(--theme-accent); }
880 +
881 + .objeto-search-wrapper input {
882 + flex: 1;
883 + border: none;
884 + background: transparent;
885 + color: var(--theme-titulo);
886 + font-size: 0.75rem;
887 + outline: none;
888 + min-width: 120px;
889 + }
890 + .objeto-search-wrapper input::placeholder { color: var(--theme-texto); opacity: 0.5; }
891 +
892 + .objeto-search-dropdown {
893 + position: absolute;
894 + top: calc(100% + 4px);
895 + left: 0;
896 + right: 0;
897 + min-width: 280px;
898 + background: var(--theme-surface);
899 + border: 1px solid var(--theme-borde);
900 + border-radius: 8px;
901 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
902 + max-height: 280px;
903 + overflow-y: auto;
904 + z-index: 100;
905 + }
906 +
907 + .objeto-search-msg { padding: 0.75rem; font-size: 0.75rem; color: var(--theme-texto); opacity: 0.6; }
908 +
909 + .objeto-search-item {
910 + display: flex;
911 + align-items: center;
912 + gap: 0.5rem;
913 + width: 100%;
914 + padding: 0.5rem 0.75rem;
915 + border: none;
916 + background: transparent;
917 + text-align: left;
918 + cursor: pointer;
919 + transition: background 0.15s;
920 + font-size: 0.75rem;
921 + color: var(--theme-titulo);
922 + }
923 + .objeto-search-item:hover { background: var(--theme-surface-hover); }
924 +
925 + .objeto-search-code {
926 + font-family: 'DM Mono', monospace;
927 + font-size: 0.6875rem;
928 + color: var(--theme-texto);
929 + min-width: 3rem;
930 + }
931 +
932 + .objeto-selected-pill {
933 + display: flex;
934 + align-items: center;
935 + gap: 0.375rem;
936 + background: rgba(201, 167, 81, 0.12);
937 + border: 1px solid rgba(201, 167, 81, 0.3);
938 + border-radius: 6px;
939 + padding: 0.25rem 0.5rem;
940 + font-size: 0.75rem;
941 + }
942 +
943 + .objeto-pill-code {
944 + font-family: 'DM Mono', monospace;
945 + font-size: 0.625rem;
946 + color: var(--theme-accent);
947 + }
948 +
949 + .objeto-pill-name {
950 + color: var(--theme-titulo);
951 + max-width: 200px;
952 + overflow: hidden;
953 + text-overflow: ellipsis;
954 + white-space: nowrap;
955 + }
956 +
957 + .objeto-pill-clear {
958 + border: none;
959 + background: transparent;
960 + color: var(--theme-texto);
961 + cursor: pointer;
962 + padding: 0 0.125rem;
963 + font-size: 0.75rem;
964 + opacity: 0.6;
965 + }
966 + .objeto-pill-clear:hover { opacity: 1; }
967 +
968 + :global(.objeto-search-name mark) {
969 + background: rgba(201, 167, 81, 0.3);
970 + color: inherit;
971 + border-radius: 2px;
972 + }
973 +
642 .option-badge { 974 .option-badge {
643 font-size: 0.55rem; 975 font-size: 0.55rem;
644 font-weight: 600; 976 font-weight: 600;
...@@ -980,6 +1312,15 @@ ...@@ -980,6 +1312,15 @@
980 opacity: 0.25; 1312 opacity: 0.25;
981 } 1313 }
982 1314
1315 + .quintil-item.hovered {
1316 + opacity: 1;
1317 + font-weight: 600;
1318 + }
1319 +
1320 + .quintil-item.faded {
1321 + opacity: 0.2;
1322 + }
1323 +
983 .quintil-color { 1324 .quintil-color {
984 width: 18px; 1325 width: 18px;
985 height: 12px; 1326 height: 12px;
......
This diff could not be displayed because it is too large.