Rafael Lopez

geo

const API_BASE = 'http://136.112.29.74/api/acteco';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
if (gestion) apiUrl.searchParams.set('gestion', gestion);
try {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/finfun';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
if (gestion) apiUrl.searchParams.set('gestion', gestion);
try {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
if (gestion) apiUrl.searchParams.set('gestion', gestion);
try {
const response = await fetch(apiUrl.toString());
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Proxy para evitar CORS
const API_BASE = 'http://136.112.29.74/api/proyecto/search';
const API_BASE = 'http://136.112.29.74/api/search';
export async function GET({ url }) {
const q = url.searchParams.get('q') || '';
......
......@@ -2,8 +2,12 @@
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { supabase } from '$lib/supabase';
import Spinner from '$lib/components/ui/Spinner.svelte';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import { initIndex } from '$lib/stores/searchStore';
......@@ -19,6 +23,7 @@
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
let vista = $state('agregado'); // 'agregado' | 'comparar'
// Parsear descripciones
function parseDescripciones(str) {
......@@ -162,9 +167,239 @@
if (data) entidades = data;
}
// ══════════════════════════════════════════════════════════════
// COMPARACIÓN
// ══════════════════════════════════════════════════════════════
const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
let hoveredYearComparar = $state(null);
let loadingComparacion = $state(false);
let isDefaultSelection = $state(false);
let alertDefaultDismissed = $state(false);
let alertRangosDismissed = $state(false);
let entidadCompararA = $state(null);
let entidadCompararB = $state(null);
let datosCompararA = $state({ anual: [], historico: null });
let datosCompararB = $state({ anual: [], historico: null });
let dropdownAOpen = $state(false);
let dropdownBOpen = $state(false);
let searchA = $state('');
let searchB = $state('');
let entityLimitA = $state(30);
let entityLimitB = $state(30);
let filteredEntidadesA = $derived.by(() => {
if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
const q = searchA.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitA);
});
let filteredEntidadesB = $derived.by(() => {
if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
const q = searchB.toLowerCase();
return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitB);
});
async function loadDatosEntidad(entidad) {
const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', entidad.entidad).order('gestion');
if (data) {
return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
}
return { anual: [], historico: null };
}
async function initComparacion() {
if (entidadCompararA || entidadCompararB) return;
if (selectedEntity) {
entidadCompararA = selectedEntity;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(selectedEntity);
loadingComparacion = false;
isDefaultSelection = false;
} else if (topEntidadesTotal.length >= 2) {
const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
if (top1 && top2) {
isDefaultSelection = true;
loadingComparacion = true;
entidadCompararA = top1;
datosCompararA = await loadDatosEntidad(top1);
entidadCompararB = top2;
datosCompararB = await loadDatosEntidad(top2);
loadingComparacion = false;
}
}
}
async function selectEntidadA(entidad) {
entidadCompararA = entidad;
dropdownAOpen = false;
searchA = '';
entityLimitA = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararA = await loadDatosEntidad(entidad);
loadingComparacion = false;
}
async function selectEntidadB(entidad) {
entidadCompararB = entidad;
dropdownBOpen = false;
searchB = '';
entityLimitB = 30;
isDefaultSelection = false;
loadingComparacion = true;
datosCompararB = await loadDatosEntidad(entidad);
loadingComparacion = false;
}
function handleEntityScroll(event, type) {
const el = event.target;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
}
}
let rangoAños = $state('todo');
let añosComunesTotales = $derived.by(() => {
const añosA = datosCompararA.anual.map(d => d.gestion);
const añosB = datosCompararB.anual.map(d => d.gestion);
if (añosA.length && añosB.length) {
const setB = new Set(añosB);
return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
}
if (añosA.length) return [...añosA].sort((a, b) => a - b);
if (añosB.length) return [...añosB].sort((a, b) => a - b);
return [];
});
let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
let rangoInfoA = $derived.by(() => {
if (!datosCompararA.anual.length) return null;
const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let rangoInfoB = $derived.by(() => {
if (!datosCompararB.anual.length) return null;
const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
return { desde: años[0], hasta: años[años.length - 1], total: años.length };
});
let tienenRangosDiferentes = $derived.by(() => {
if (!rangoInfoA || !rangoInfoB) return false;
return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
});
let añosComparacion = $derived.by(() => {
if (rangoAños === 'todo') return añosComunesTotales;
return añosComunesTotales.slice(-parseInt(rangoAños));
});
let datosGraficoA = $derived.by(() => {
const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
let datosGraficoB = $derived.by(() => {
const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
});
let maxComparacion = $derived.by(() => {
const all = [...datosGraficoA.map(d => d.per_capita || 0), ...datosGraficoB.map(d => d.per_capita || 0)];
return Math.max(...all, 1) * 1.1;
});
let displayPeriodoComparar = $derived.by(() => {
if (hoveredYearComparar) return hoveredYearComparar;
if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
return 'Selecciona entidades';
});
let displayPropA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
return datosCompararA.historico?.prop || 0;
});
let displayPropB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
return datosCompararB.historico?.prop || 0;
});
let displayRankingA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
return datosCompararA.historico?.ranking || '-';
});
let displayRankingB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
return datosCompararB.historico?.ranking || '-';
});
let displayNEntidadesA = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
return datosCompararA.historico?.n_entidades || '-';
});
let displayNEntidadesB = $derived.by(() => {
if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
return datosCompararB.historico?.n_entidades || '-';
});
let isDarkMode = $state(false);
$effect(() => {
if (typeof document !== 'undefined') {
const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
checkDark();
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
});
let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
function toNumber(v) { return typeof v === 'number' ? v : 0; }
$effect(() => {
const year = hoveredYearComparar;
const idx = year ? añosComparacion.indexOf(year) : -1;
twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
});
// Watch vista changes
let previousVista = 'agregado';
$effect(() => {
if (mounted && vista !== previousVista) {
previousVista = vista;
if (vista === 'comparar') initComparacion();
}
});
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
return m?.toLocaleString('es-BO') || '0';
}
// ══════════════════════════════════════════════════════════════
onMount(() => {
mounted = true;
initIndex();
// Read URL params
const urlVista = $page.url.searchParams.get('vista');
if (urlVista === 'comparar') vista = 'comparar';
});
// Recargar entidades cuando cambia el objeto
......@@ -174,6 +409,10 @@
if (code !== prevCodigo) {
prevCodigo = code;
selectedEntity = null;
entidadCompararA = null;
entidadCompararB = null;
datosCompararA = { anual: [], historico: null };
datosCompararB = { anual: [], historico: null };
loadEntidades();
}
});
......@@ -201,6 +440,7 @@
</a>
</nav>
{#if vista === 'agregado'}
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
......@@ -268,13 +508,13 @@
<span class="explore-count">7 →</span>
</div>
<div class="explore-scroll">
<a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
<div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<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"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
</a>
</div>
<a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
......@@ -456,6 +696,167 @@
</div>
</div>
{/if}
{:else}
<!-- VISTA COMPARAR -->
<main class="main-comparar">
<div class="chart-card-comparar">
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{objetoData.objeto}</span>
</div>
<VistaToggle bind:vista />
</div>
<h2 class="main-title-name">{objetoData.desc_objeto}</h2>
<div class="comparador-selectors">
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorA}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
<span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
<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>
</button>
{#if dropdownAOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
{#each filteredEntidadesA as entity}
<button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<span class="vs">vs</span>
<div class="selector-group comparador-dropdown-container">
<span class="selector-dot" style="background: {colorB}"></span>
<div class="relative" style="flex: 1;">
<button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
<span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
<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>
</button>
{#if dropdownBOpen}
<div class="dropdown-panel comparador-panel">
<div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
{#each filteredEntidadesB as entity}
<button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
<div class="comparador-hint">
<span>Mostrando las dos entidades con mayor gasto. Puedes cambiar la selección arriba.</span>
<button class="alert-close" onclick={() => alertDefaultDismissed = true}>✕</button>
</div>
{/if}
{#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
<div class="comparador-warning">
<span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} años en común.</span>
<button class="alert-close" onclick={() => alertRangosDismissed = true}>✕</button>
</div>
{/if}
{#if loadingComparacion}
<div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
{:else if !entidadCompararA && !entidadCompararB}
<div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
{:else if añosComparacion.length === 0}
<div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
{:else}
<div class="chart-header">
<span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodoComparar}</span></span>
</div>
<div class="chart-comparar-wrapper">
<div class="chart-comparar-inner">
<span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
<span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
<span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
<div class="grid-line-comparar" style="bottom: 100%"></div>
<div class="grid-line-comparar" style="bottom: 50%"></div>
<div class="grid-line-comparar" style="bottom: 0%"></div>
<div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
{#each añosComparacion as año, i}
<div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
<div class="bars-pair">
<div class="bar-comparar" style="height: {((datosGraficoA[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorA}"></div>
<div class="bar-comparar" style="height: {((datosGraficoB[i]?.per_capita || 0) / maxComparacion) * 100}%; background: {colorB}"></div>
</div>
</div>
{/each}
</div>
</div>
<div class="x-axis-comparar">
{#each añosComparacion as año, i}
{@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
<span class="x-label-comparar" class:visible={show}>{año}</span>
{/each}
</div>
</div>
<div class="chart-legend">
{#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
{#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
</div>
{/if}
</div>
{#if soloUnaEntidad}
<div class="comparador-hint comparador-hint-select">
<span>Selecciona la segunda entidad para ver la comparación completa.</span>
</div>
{/if}
{#if entidadCompararA && entidadCompararB}
<div class="kpis-card-comparar">
<div class="tabla-header">
<span class="tabla-label"></span>
<span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
<span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Gasto</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
<span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Per cápita</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
<span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Peso</span>
<span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
</div>
<div class="tabla-row">
<span class="tabla-label">Ranking</span>
<span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
<span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
</div>
</div>
{/if}
</main>
{/if}
</div>
<style>
......@@ -1298,4 +1699,324 @@
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
/* ═══════════════════════════════════════════════════════════════
COMPARAR VIEW
═══════════════════════════════════════════════════════════════ */
.main-comparar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 2rem 1.5rem;
width: 100%;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 60px);
}
.chart-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.comparador-selectors {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
.selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
.relative { position: relative; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selector-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 8px;
font-size: 0.8125rem;
color: var(--theme-titulo);
cursor: pointer;
transition: border-color 0.2s;
}
.selector-btn:hover { border-color: var(--theme-accent); }
.comparador-btn { width: 100%; justify-content: space-between; }
.comparador-dropdown-container { position: relative; }
.dropdown-panel.comparador-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
min-width: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
}
.search-input:focus { border-color: var(--theme-accent); }
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover { background: var(--theme-surface-hover); }
.dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
.dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
.dropdown-list { max-height: 240px; overflow-y: auto; }
.entity-code { font-family: 'DM Mono', monospace; font-size: 0.6875rem; color: var(--theme-texto); min-width: 2rem; }
.comparador-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed var(--theme-borde);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
.comparador-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(107, 159, 212, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #6B9FD4;
}
.comparador-hint span { flex: 1; }
.comparador-hint-select { margin-top: 0.75rem; }
.comparador-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(230, 176, 75, 0.1);
border-radius: 8px;
font-size: 0.8125rem;
color: #C9A751;
}
.comparador-warning span { flex: 1; }
.alert-close {
flex-shrink: 0;
padding: 0.25rem;
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
border-radius: 4px;
}
.alert-close:hover { opacity: 1; }
.chart-comparar-wrapper {
position: relative;
width: 100%;
flex: 1;
min-height: 180px;
}
.chart-comparar-inner {
position: absolute;
top: 10px;
bottom: 30px;
left: 50px;
right: 10px;
}
.y-label-comparar {
position: absolute;
left: -50px;
width: 45px;
text-align: right;
transform: translateY(50%);
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
white-space: nowrap;
}
.grid-line-comparar {
position: absolute;
left: 0;
right: 0;
border-top: 0.5px dotted var(--theme-texto);
opacity: 0.15;
}
.bars-comparar {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: 3px;
}
.bar-group-comparar {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: pointer;
}
.bars-pair {
display: flex;
align-items: flex-end;
gap: 2px;
height: 100%;
width: 100%;
justify-content: center;
}
.bar-comparar {
flex: 1;
max-width: 24px;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: opacity 0.15s;
}
.bars-comparar:hover .bar-comparar { opacity: 0.35; }
.bar-group-comparar.active .bar-comparar { opacity: 1; }
.x-axis-comparar {
position: absolute;
bottom: 0;
left: 50px;
right: 10px;
height: 30px;
display: flex;
align-items: center;
}
.x-label-comparar {
flex: 1;
text-align: center;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s;
}
.x-label-comparar.visible { opacity: 1; }
.chart-legend {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--theme-texto);
}
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
.kpis-card-comparar {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem 1.25rem;
}
.tabla-header {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--theme-borde);
margin-bottom: 0.25rem;
}
.tabla-header .tabla-val {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
.tabla-entidad {
font-size: 0.6875rem;
line-height: 1.3;
word-break: break-word;
}
.tabla-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1.2fr;
gap: 0.75rem;
padding: 0.1875rem 0;
}
.tabla-label {
font-size: 0.6875rem;
font-weight: 500;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tabla-val {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
text-align: center;
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.main-comparar { padding: 0.75rem; }
.chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
.comparador-selectors { flex-direction: column; gap: 0.75rem; }
.vs { display: none; }
}
</style>
......
<script>
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
import { mapaCache } from '$lib/stores/mapaCache';
......@@ -15,7 +17,7 @@
let hoveredMunicipio = $state(null);
let drawerOpen = $state(false);
let hoveredQuintil = $state(-1);
let activeQuintiles = $state(new Set([0, 1, 2, 3, 4]));
let activeQuintiles = $state(new Set([0, 1, 2, 3]));
let sortDrawer = $state('monto');
let sortDrawerOrder = $state('desc');
let mostrarDepartamentos = $state(false);
......@@ -26,11 +28,123 @@
const CLASIFICADORES_MAPA = [
{ id: 'total', label: 'Gasto total', disponible: true },
{ id: 'objeto', label: 'Objetos de gasto', disponible: false },
{ id: 'finfun', label: 'Finalidad y función', disponible: false },
{ id: 'acteco', label: 'Sectores económicos', disponible: false },
{ id: 'objeto', label: 'Objetos de gasto', disponible: true },
{ id: 'finfun', label: 'Finalidad y función', disponible: true },
{ id: 'acteco', label: 'Sectores económicos', disponible: true },
];
// Búsqueda de partida para clasificadores (objeto, finfun, acteco)
let clasSearchVal = $state('');
let clasSearchResults = $state([]);
let clasSearchFocused = $state(false);
let clasSearchLoading = $state(false);
let clasSeleccionado = $state(null);
let clasDebounce;
const CLAS_SEARCH_MAP = {
objeto: { class_: 'objeto', codigoFn: (meta) => meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '' },
finfun: { class_: 'finfun', codigoFn: (meta) => {
const fin = String(meta.finfun_finalidad || '');
if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
if (meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}`;
return fin;
}},
acteco: { class_: 'acteco', codigoFn: (meta) => {
const s = String(meta.acteco_sector ? Math.round(meta.acteco_sector) : '');
if (meta.acteco_actividad !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}.${Math.round(meta.acteco_actividad)}`;
if (meta.acteco_subsector !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}`;
return s;
}},
};
const CLAS_API_MAP = {
objeto: '/api/objeto-ubigeos',
finfun: '/api/finfun-ubigeos',
acteco: '/api/acteco-ubigeos',
};
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
async function buscarClasificador(query) {
clasSearchLoading = true;
const cfg = CLAS_SEARCH_MAP[clasificadorSeleccionado];
if (!cfg) { clasSearchLoading = false; return; }
try {
const params = new URLSearchParams({ q: query, per_page: '15', is_class: 'true', class_: cfg.class_ });
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
clasSearchResults = (data.hits || []).map(hit => {
const meta = parseMetadatos(hit.document.metadatos);
return { codigo: cfg.codigoFn(meta), nombre: hit.document.texto, highlight: hit.highlights?.[0]?.snippet || hit.document.texto };
});
} catch { clasSearchResults = []; }
clasSearchLoading = false;
}
function handleClasInput(e) {
clasSearchVal = e.target.value;
clearTimeout(clasDebounce);
if (clasSearchVal.length >= 2) {
clasDebounce = setTimeout(() => buscarClasificador(clasSearchVal), 200);
} else {
clasSearchResults = [];
}
}
async function seleccionarClasificador(item) {
clasSeleccionado = item;
clasSearchVal = '';
clasSearchResults = [];
clasSearchFocused = false;
await cargarClasUbigeos(item.codigo, gestionSeleccionada);
updateMapUrl();
}
function limpiarClasificador() {
clasSeleccionado = null;
resumen = [];
updateMapUrl();
}
// Cache de datos clasificador-ubigeo
let clasUbigeoCache = $state({});
async function cargarClasUbigeos(codigo, gestion) {
const apiEndpoint = CLAS_API_MAP[clasificadorSeleccionado];
if (!apiEndpoint) return;
const key = `${clasificadorSeleccionado}_${codigo}_${gestion}`;
if (clasUbigeoCache[key]) {
resumen = clasUbigeoCache[key];
return;
}
const params = new URLSearchParams({ codigo });
if (gestion) params.set('gestion', gestion);
try {
const res = await fetch(`${apiEndpoint}?${params}`);
const data = await res.json();
const mapped = (data || [])
.filter(d => d.ubigeo !== '0.0.0')
.map(d => ({
codigo: d.ubigeo,
desc: d.desc_ubigeo,
gestion: d.gestion,
devengado: d.monto,
ranking: d.ranking,
per_capita: d.per_capita,
departamento: d.desc_departamento
}));
clasUbigeoCache[key] = mapped;
resumen = mapped;
} catch {
resumen = [];
}
}
function titleCase(str) {
if (!str) return '';
return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
......@@ -42,6 +156,7 @@
const map = {};
mapaData.municipios.features.forEach(f => {
map[String(f.properties.codigo)] = f.properties.DEPARTAMEN;
if (f.properties.ubigeo) map[f.properties.ubigeo] = f.properties.DEPARTAMEN;
});
return map;
});
......@@ -68,24 +183,26 @@
// Crear mapa de datos por código (cacheado, no función)
let datosPorCodigo = $derived.by(() => {
console.time('datosPorCodigo');
const map = {};
const esClasificador = clasificadorSeleccionado !== 'total' && clasSeleccionado;
resumen.forEach(d => {
const val = modoPerCapita && poblacionMap[d.codigo]
? d.devengado / poblacionMap[d.codigo]
: d.devengado;
let val;
if (modoPerCapita) {
val = esClasificador ? (d.per_capita || 0) : (poblacionMap[d.codigo] ? d.devengado / poblacionMap[d.codigo] : d.devengado);
} else {
val = d.devengado;
}
map[d.codigo] = { ...d, valor: val };
});
console.timeEnd('datosPorCodigo');
return map;
});
// Detectar modo oscuro
let isDark = $state(false);
// 5 quintiles
const colorsLight = ['#f2ece6', '#e0c8b0', '#c4897d', '#a86858', '#8B4A3A'];
const colorsDark = ['#2E2B27', '#4A4035', '#6B5A48', '#9A8050', '#C9A751'];
// 4 cuartiles
const colorsLight = ['#e0c8b0', '#c4897d', '#a86858', '#8B4A3A'];
const colorsDark = ['#4A4035', '#6B5A48', '#9A8050', '#C9A751'];
let palette = $derived(isDark ? colorsDark : colorsLight);
......@@ -151,9 +268,9 @@
function opacidadMuni(codigo) {
if (hoveredQuintil === -1) return 1;
const datos = datosPorCodigo[codigo];
if (!datos) return 0.15;
if (!datos) return 0.02;
const qi = getQuintilIdx(datos.valor);
return qi === hoveredQuintil ? 1 : 0.15;
return qi === hoveredQuintil ? 1 : 0.02;
}
function toggleQuintil(idx) {
......@@ -236,9 +353,15 @@
let resumenCache = $state({});
async function cargarGestion(gestion) {
console.time('cargarGestion');
// Si hay un clasificador seleccionado, cargar desde la API
if (clasificadorSeleccionado !== 'total' && clasSeleccionado) {
await cargarClasUbigeos(clasSeleccionado.codigo, gestion);
poblacionMap = await getPoblacionAno(gestion);
return;
}
// Modo total: cargar desde Supabase
if (!resumenCache[gestion]) {
console.time('supabase');
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_resumen')
......@@ -248,15 +371,44 @@
.eq('gestion', gestion)
.order('devengado', { ascending: false })
.limit(500);
console.timeEnd('supabase');
resumenCache[gestion] = rows || [];
}
resumen = resumenCache[gestion];
console.time('poblacion');
poblacionMap = await getPoblacionAno(gestion);
console.timeEnd('poblacion');
console.timeEnd('cargarGestion');
}
// Actualizar URL sin recargar
function updateMapUrl() {
const url = new URL($page.url);
url.searchParams.delete('clas');
url.searchParams.delete('codigo');
url.searchParams.delete('gestion');
if (gestionSeleccionada !== 2025) url.searchParams.set('gestion', gestionSeleccionada);
if (clasificadorSeleccionado !== 'total' && clasSeleccionado) {
url.searchParams.set('clas', clasificadorSeleccionado);
url.searchParams.set('codigo', clasSeleccionado.codigo);
}
goto(url.toString(), { replaceState: true, noScroll: true });
}
// Resolver nombre de partida por código desde Typesense
async function resolverNombre(clas, codigo) {
const cfg = CLAS_SEARCH_MAP[clas];
if (!cfg) return codigo;
try {
const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ });
const res = await fetch(`/api/search?${params}`);
if (!res.ok) return codigo;
const data = await res.json();
for (const hit of (data.hits || [])) {
const meta = parseMetadatos(hit.document.metadatos);
const hitCodigo = cfg.codigoFn(meta);
if (hitCodigo === codigo) return hit.document.texto;
}
} catch {}
return codigo;
}
onMount(async () => {
......@@ -284,6 +436,23 @@
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Leer URL params
const urlClas = $page.url.searchParams.get('clas');
const urlCodigo = $page.url.searchParams.get('codigo');
const urlGestion = $page.url.searchParams.get('gestion');
if (urlGestion) {
gestionSeleccionada = parseInt(urlGestion);
await cargarGestion(gestionSeleccionada);
}
if (urlClas && urlCodigo && CLAS_API_MAP[urlClas]) {
clasificadorSeleccionado = urlClas;
const nombre = await resolverNombre(urlClas, urlCodigo);
clasSeleccionado = { codigo: urlCodigo, nombre };
await cargarClasUbigeos(urlCodigo, gestionSeleccionada);
}
function handleClickOutside(e) {
if (gestionDropdownOpen && !e.target.closest('.gestion-selector')) {
gestionDropdownOpen = false;
......@@ -329,7 +498,20 @@
class="clasificador-option"
class:active={clasificadorSeleccionado === cls.id}
class:disabled={!cls.disponible}
onclick={() => { if (cls.disponible) { clasificadorSeleccionado = cls.id; clasificadorDropdownOpen = false; } }}
onclick={() => {
if (cls.disponible) {
clasificadorSeleccionado = cls.id;
clasificadorDropdownOpen = false;
clasSeleccionado = null;
clasSearchVal = '';
clasSearchResults = [];
if (cls.id === 'total') {
cargarGestion(gestionSeleccionada);
} else {
resumen = [];
}
}
}}
>
<span class="option-label">{cls.label}</span>
{#if cls.sub}
......@@ -340,6 +522,45 @@
</div>
{/if}
</div>
{#if clasificadorSeleccionado !== 'total'}
<span class="titulo-sep">·</span>
<div class="objeto-search-mapa">
{#if clasSeleccionado}
<div class="objeto-selected-pill">
<span class="objeto-pill-code">{clasSeleccionado.codigo}</span>
<span class="objeto-pill-name">{clasSeleccionado.nombre}</span>
<button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button>
</div>
{:else}
<div class="objeto-search-wrapper" class:focused={clasSearchFocused}>
<input
type="text"
value={clasSearchVal}
oninput={handleClasInput}
onfocus={() => clasSearchFocused = true}
onblur={() => setTimeout(() => clasSearchFocused = false, 150)}
placeholder="Buscar {clasificadorSeleccionado === 'objeto' ? 'partida' : clasificadorSeleccionado === 'finfun' ? 'finalidad o función' : 'sector'}..."
/>
{#if clasSearchFocused && clasSearchVal.length >= 2}
<div class="objeto-search-dropdown">
{#if clasSearchLoading}
<div class="objeto-search-msg">Buscando...</div>
{:else if clasSearchResults.length === 0}
<div class="objeto-search-msg">Sin resultados</div>
{:else}
{#each clasSearchResults as result}
<button class="objeto-search-item" onclick={() => seleccionarClasificador(result)}>
<span class="objeto-search-code">{result.codigo}</span>
<span class="objeto-search-name">{@html result.highlight}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<span class="titulo-sep">por geografía</span>
<div class="gestion-selector">
<button class="gestion-btn" onclick={() => { gestionDropdownOpen = !gestionDropdownOpen; }}>
......@@ -350,11 +571,11 @@
</button>
{#if gestionDropdownOpen}
<div class="gestion-dropdown">
{#each data.gestiones as g}
{#each [...data.gestiones].reverse() as g}
<button
class="gestion-option"
class:active={gestionSeleccionada === g}
onclick={() => { gestionSeleccionada = g; gestionDropdownOpen = false; cargarGestion(g); }}
onclick={async () => { gestionSeleccionada = g; gestionDropdownOpen = false; await cargarGestion(g); updateMapUrl(); }}
>
{g}
</button>
......@@ -390,7 +611,7 @@
<path d={pathGen(mapaData.bolivia)} class="mapa-pais-fill" />
<path d={pathGen(mapaData.bolivia)} class="mapa-pais-outline" />
{#each mapaData.municipios.features as feat}
{@const cod = String(feat.properties.codigo)}
{@const cod = clasificadorSeleccionado !== 'total' ? feat.properties.ubigeo : String(feat.properties.codigo)}
{@const datos = datosPorCodigo[cod]}
{@const visible = enRango(cod)}
{@const color = datos && visible ? colorScale(datos.valor) : 'transparent'}
......@@ -398,8 +619,8 @@
<path
d={pathGen(feat)}
fill={color}
stroke={hoveredMunicipio === cod ? (isDark ? '#F5F0E8' : '#1d1d1f') : (visible ? (isDark ? 'rgba(255,255,255,0.05)' : 'white') : 'transparent')}
stroke-width={hoveredMunicipio === cod ? 2 : 0.3}
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')}
stroke-width={hoveredMunicipio === cod ? 2 : (hoveredQuintil !== -1 && datos && getQuintilIdx(datos.valor) === hoveredQuintil) ? 0.8 : 0.3}
opacity={visible ? opacidadMuni(cod) : 0}
style="cursor: pointer; transition: fill 0.2s, opacity 0.2s;"
onmouseenter={() => { if (visible) hoveredMunicipio = cod; }}
......@@ -422,6 +643,8 @@
<button
class="quintil-item"
class:dimmed={!activeQuintiles.has(i)}
class:hovered={hoveredQuintil === i}
class:faded={hoveredQuintil !== -1 && hoveredQuintil !== i}
onmouseenter={() => { hoveredQuintil = i; }}
onclick={() => { toggleQuintil(i); }}
>
......@@ -639,6 +862,115 @@
.option-label { flex: 1; }
.option-sub { font-size: 0.65rem; color: var(--theme-texto); opacity: 0.5; }
/* Buscador de objeto en mapa */
.objeto-search-mapa {
position: relative;
}
.objeto-search-wrapper {
display: flex;
align-items: center;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 6px;
padding: 0.25rem 0.5rem;
min-width: 180px;
}
.objeto-search-wrapper.focused { border-color: var(--theme-accent); }
.objeto-search-wrapper input {
flex: 1;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.75rem;
outline: none;
min-width: 120px;
}
.objeto-search-wrapper input::placeholder { color: var(--theme-texto); opacity: 0.5; }
.objeto-search-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
min-width: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 280px;
overflow-y: auto;
z-index: 100;
}
.objeto-search-msg { padding: 0.75rem; font-size: 0.75rem; color: var(--theme-texto); opacity: 0.6; }
.objeto-search-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.15s;
font-size: 0.75rem;
color: var(--theme-titulo);
}
.objeto-search-item:hover { background: var(--theme-surface-hover); }
.objeto-search-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
min-width: 3rem;
}
.objeto-selected-pill {
display: flex;
align-items: center;
gap: 0.375rem;
background: rgba(201, 167, 81, 0.12);
border: 1px solid rgba(201, 167, 81, 0.3);
border-radius: 6px;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.objeto-pill-code {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
}
.objeto-pill-name {
color: var(--theme-titulo);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.objeto-pill-clear {
border: none;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
padding: 0 0.125rem;
font-size: 0.75rem;
opacity: 0.6;
}
.objeto-pill-clear:hover { opacity: 1; }
:global(.objeto-search-name mark) {
background: rgba(201, 167, 81, 0.3);
color: inherit;
border-radius: 2px;
}
.option-badge {
font-size: 0.55rem;
font-weight: 600;
......@@ -980,6 +1312,15 @@
opacity: 0.25;
}
.quintil-item.hovered {
opacity: 1;
font-weight: 600;
}
.quintil-item.faded {
opacity: 0.2;
}
.quintil-color {
width: 18px;
height: 12px;
......
This diff could not be displayed because it is too large.