Showing
8 changed files
with
1197 additions
and
30 deletions
src/routes/api/acteco-ubigeos/+server.js
0 → 100644
| 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 | +} |
src/routes/api/finfun-ubigeos/+server.js
0 → 100644
| 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 | +} |
src/routes/api/objeto-ubigeos/+server.js
0 → 100644
| 1 | +const API_BASE = 'http://136.112.29.74/api/objeto'; | ||
| 2 | + | ||
| 3 | +export async function GET({ url }) { | ||
| 4 | + const codigo = url.searchParams.get('codigo') || ''; | ||
| 5 | + const 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; | ... | ... |
static/gap.png
deleted
100644 → 0
380 KB
This diff could not be displayed because it is too large.
-
Please register or login to post a comment